Files
daily_stock_analysis/src/analyzer.py
青玉案 d0e66a1dc3 feat: 新闻检索为空时在报告中如实标注 (#2229)
* feat: 新闻检索为空时在报告中如实标注

消息面章节此前是「有内容才渲染」,检索一条没拿到时整段直接消失,
读报告的人无从判断是确实没新闻,还是检索静默失败了(搜索源限流、
未配置可用渠道等)。这把「抓取失败」呈现成了「确实没有新闻」。

- src/analyzer.py: AnalysisResult 新增 news_result_count,默认 None
- src/core/pipeline.py: 把 Step 4 已算好的计数交给结果对象
  (此前只进了 diagnostic context snapshot,报告层拿不到)
- src/notification.py: news_lines 为空且计数为 0 时,渲染明确提示,
  并说明结论未纳入新闻维度证据
- tests: 新增 5 条用例,含两条负例——计数为 None 时不得报警
  (那是未配置搜索渠道,不是失败)、拿到新闻时行为与改动前一致

不触碰任何检索路径,纯展示层增量。

* fix: 把新闻缺失提示放进真实渲染路径,并独立于模型输出判定

按 review 三条意见修正:

P1-1 提示只存在于 generate_daily_report,而正常流程从不调用它——
_send_single_stock_notification 与聚合报告走的是 dashboard / brief /
single_stock。原实现对所有标准 REPORT_TYPE 都不生效。
改为抽出共享判定 _empty_news_disclosure,四个渲染器统一接入。

P2 检索零命中但模型按 schema 写出了 market_sentiment / hot_topics 时,
原 elif 分支被跳过,报告会展示模型生成的情绪判断却隐瞒无新闻证据。
改为独立判定 news_result_count == 0,与模型是否产出文字无关。

P1-2 补 docs/CHANGELOG.md [Unreleased] 条目,并在
docs/data-source-stability.md 的「用户可见提示建议」一节记录该行为,
含 None / 0 / >0 三态语义表。

测试从 5 条增至 10 条,新增覆盖 dashboard、brief、single_stock 三个真实
渲染器,以及「模型有输出但检索为空」这一最糟组合。39 passed

* fix: 把新闻零命中披露覆盖到模板链路与企业微信入口

按 review 指出的 blocker 修正。此前只接了字符串拼接分支,遗漏两类活路径:

1. REPORT_RENDERER_ENABLED=true 时,generate_dashboard_report /
   generate_brief_report / generate_wechat_dashboard 会先 return render(...),
   模板链路一路不渲染披露;
2. generate_wechat_dashboard 的非模板 fallback 从未接入,而 pipeline 在
   企业微信非 brief 场景会直接调用它。

后果是同一份分析结果在部分渠道披露、在另一些渠道沉默。

改法不再逐点打补丁,而是抽出单一事实来源:

- 新增 src/services/empty_news.py 持有判定与中英文案
- src/notification.py 的 _empty_news_disclosure 改为委托该模块
- src/services/report_renderer.py 为每条结果预计算 empty_news_disclosure,
  三个平台模板共用
- templates/report_markdown.j2 / report_brief.j2 / report_wechat.j2 各加渲染分支
- generate_wechat_dashboard 的 fallback 正文接入披露

新增 6 条回归测试:模板链路三个平台各一条、企业微信入口一条,
外加两条负例(未执行检索时模板与企业微信均不得提示)。

本文件测试 10 → 16 全过;全量 5824 passed,9 个既有失败与本 PR 无关
(干净 main 上同样失败,属测试顺序依赖)。

* fix: 修正计数源头的两处缺口(自查发现)

按 review 的 merge-base..HEAD 方法自查全链路,发现此前几轮都只盯着渲染出口,
从未核对计数源头,而源头本身在两条路径上是错的:

1. src/core/pipeline.py: news_result_count 只在 intel_results 非空时赋值,
   搜索服务整体失败(正是所有搜索源限流全挂的场景)时停留在 None,
   语义为「未执行检索」,于是本 PR 想解决的头号场景反而不提示。
   改为检索一发起即置 0。

2. _analyze_with_agent: Agent 模式自行调用 search_stock_news 完成检索,
   却从不回写计数,该路径下零命中永远静默。改为按检索结果回写 0 或实际条数。

渲染层再周全,源头数据不对则全部落空。

新增 2 条测试锁住这两处语义(18 passed,此前 16)。
全量 5826 passed,9 个既有失败与本 PR 无关。

* fix: disclose missing news search configuration

* chore: remove unrelated agent guidance

* test: run all empty news tests directly

* fix: preserve empty news disclosure across reports

* fix: 让 Agent 模式的新闻披露跟随实际消费的证据

原问题:agent_arch=multi 等受支持的 Agent 配置下,报告可能声称「未纳入新闻
面证据」而分析其实用了新闻,或反过来该提示而不提示。

根因:news_result_count 取自 executor.run() 结束后为持久化情报补打的一次
search_stock_news()。真实情报由 IntelAgent 通过 search_comprehensive_intel
取得,两者不等价,因此披露与真实证据链可能相反。

修复点:新增 src/agent/news_evidence.py,以运行期证据作用域收集 Agent 搜索
工具的真实返回条数;搜索渠道不可用为 None(未执行检索),可用则从 0 起步、
拿到多少算多少。pipeline 在 executor.run() 前后开启并读取该作用域,事后的
持久化补查不再回写计数。

回归风险:工具在 ThreadPoolExecutor 中执行,runner.py 以
contextvars.copy_context() 提交,故作用域中必须是可变累加器对象,换成不可变
值会让父线程读不到;已加回归测试锁住该机制。原 test_agent_path_records_count
断言的正是被修复的错误行为,已替换为反向断言。

Refs #2225

* fix: 让新闻披露以实际证据为准而非搜索命中数

原问题:本地已落库的资讯池或社交情绪进入 news_context 参与分析后,报告仍可能
声称「未配置搜索渠道,本次分析未纳入新闻面证据」或「零命中」。

根因:news_context 由三路来源拼成——实时检索、社交情绪(美股)、本地资讯池,
但只有实时检索会更新 news_result_count。披露断言的是「结论有没有用到新闻面
证据」,而计数只是「搜索命中了几条」,两者是不同命题,后两路参与时必然失真。

修复点:AnalysisResult 新增 news_evidence_present,由 news_context 是否非空
得出,pipeline 两条路径共用 src/services/empty_news.news_evidence_present()
这一个判定函数。披露改为先看有无证据;确无证据时才用计数解释原因
(None=未配置渠道,0=检索零命中)。历史重建同步恢复该字段。

回归风险:旧记录没有该字段,按计数回退推断,与该记录当时的报告表现一致,不会
追溯改变旧报告;已有用例锁住。review 只点名了本地资讯池,社交情绪属同一缺陷类,
本次一并修复并加测试。另加源码断言:任一 pipeline 路径改回只传计数即失败。

Refs #2225

* fix: 按来源登记新闻证据,不让零命中占位文本冒充证据

原问题:普通分析链路在「搜索已执行但一条证据都没拿到」时,报告不再显示零命中
披露——正是本 PR 要修的核心场景,反而比改动前更差。

根因:src/search_service.py 的 format_intel_report() 即使所有维度失败或为空,
也会输出「【XX 情报搜索结果】」标题和每个维度的「未找到相关信息」占位文本,
整段永远非空。上一版把拼好的 news_context 整段交给 news_evidence_present()
判定,于是 news_result_count == 0 时 evidence 被翻成 true,披露被吞掉,错误
状态还会经 to_dict() 持久化,继续影响历史、详情 API 与 Web。

修复点:判定改为按来源逐个登记——实时检索的真实命中数、社交情绪内容、本地
资讯池内容,任一为真才算有证据;两条 pipeline 路径都不再传拼好的整段。
news_evidence_present() 的契约随之改为接收各来源,并在文档串里写明为什么不能
传整段。

回归风险:新增反例用真实的 format_intel_report() 产出占位文本(不用 mock),
断言其不得被判成证据、且报告必须出现零命中披露。另有源码断言:谁把整段
news_context 交回判定函数即失败。上一版两条测试实际在保护该缺陷(一条名为
「任何非空 context 都算证据」,一条要求必须传入 news_context),已一并纠正。

Refs #2225

---------

Co-authored-by: Mach-Chan <zz-b240@zz-b240deMacBook-Air.local>
2026-08-22 21:27:04 +08:00

5125 lines
216 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
===================================
A股自选股智能分析系统 - AI分析层
===================================
职责:
1. 封装 LLM 调用逻辑(通过 LiteLLM 统一调用 Gemini/Anthropic/OpenAI 等)
2. 结合技术面和消息面生成分析报告
3. 解析 LLM 响应为结构化 AnalysisResult
"""
import json
import logging
import math
import re
import time
from dataclasses import dataclass
from typing import Optional, Dict, Any, List, Tuple, Callable, Union
import litellm
from json_repair import repair_json
from litellm import Router
from src.agent.llm_adapter import (
get_thinking_extra_body,
resolve_fallback_litellm_wire_models,
register_fallback_model_pricing,
)
from src.agent.provider_trace import resolved_model_provider_identity
from src.agent.skills.defaults import CORE_TRADING_SKILL_POLICY_ZH
from src.config import (
Config,
extra_litellm_params,
get_api_keys_for_model,
get_config,
get_configured_llm_models,
get_explicit_llm_channel_model_provider,
resolve_news_window_days,
)
from src.llm.hermes import (
HERMES_CHANNEL_NAME,
build_hermes_redaction_values,
canonicalize_hermes_model_ref,
filter_non_hermes_deployments,
hermes_blocked_route_candidates,
is_masked_secret_placeholder,
open_hermes_no_proxy_client,
route_deployment_origins,
route_has_hermes,
sanitize_hermes_error_text,
)
from src.llm.generation_params import apply_litellm_generation_params
from src.llm.errors import call_litellm_with_param_recovery
from src.llm.backend_registry import (
LOCAL_CLI_GENERATION_BACKEND_IDS,
LITELLM_BACKEND_ID,
resolve_generation_backend_id,
resolve_generation_fallback_backend_id,
)
from src.llm.backend_factory import create_generation_backend
from src.llm.generation_backend import (
GenerationBackend,
GenerationError,
GenerationErrorCode,
GenerationResult,
)
from src.llm.usage import (
attach_legacy_message_stability_audit,
attach_message_hmacs,
extract_usage_payload,
normalize_litellm_usage,
should_persist_usage_telemetry,
)
from src.llm.local_cli_backend import redact_diagnostic_text
from src.llm.provider_cache import (
apply_prompt_cache_hints,
build_provider_cache_route_context,
filter_prompt_cache_telemetry,
)
from src.llm.response_content import strip_leading_think_wrapper
from src.storage import persist_llm_usage
from src.data.stock_mapping import STOCK_NAME_MAP
from src.report_language import (
get_signal_level,
get_no_data_text,
get_placeholder_text,
get_unknown_text,
get_chip_unavailable_text,
infer_decision_type_from_advice,
is_chip_placeholder_value,
localize_chip_health,
localize_confidence_level,
localize_operation_advice,
localize_trend_prediction,
normalize_report_language,
)
from src.schemas.decision_action import build_action_fields
from src.schemas.decision_scale import (
CANONICAL_DECISION_SCALE_PROMPT_ZH,
score_band_metadata,
)
from src.schemas.report_schema import AnalysisReportSchema
from src.market_context import detect_market, get_market_role, get_market_guidelines
from src.services.daily_market_context import format_daily_market_context_prompt_section
from src.market_phase_prompt import format_market_phase_prompt_section
from src.market_structure_prompt import format_market_structure_prompt_section
logger = logging.getLogger(__name__)
def _localized_text(language: Any, *, en: str, zh: str, ko: str) -> str:
"""Pick a deterministic fallback string for the report language (zh/en/ko)."""
normalized = normalize_report_language(language)
if normalized == "en":
return en
if normalized == "ko":
return ko
return zh
def _normalize_risk_warning_values(value: Any) -> List[str]:
"""Normalize arbitrary risk_warning values into a flat list of text alerts."""
if value is None:
return []
if isinstance(value, str):
text = value.strip()
return [text] if text else []
if isinstance(value, (list, tuple, set)):
normalized: List[str] = []
for item in value:
normalized.extend(_normalize_risk_warning_values(item))
return normalized
if isinstance(value, dict):
if not value:
return []
try:
dumped = json.dumps(value, ensure_ascii=False)
text = dumped.strip()
except (TypeError, ValueError):
text = str(value).strip()
return [text] if text else []
text = str(value).strip()
return [text] if text else []
def _today_has_realtime_overlay(today: Any) -> bool:
if not isinstance(today, dict):
return False
data_source = today.get("data_source") or today.get("dataSource")
if isinstance(data_source, str) and data_source.startswith("realtime:"):
return True
if today.get("is_partial_bar") is True or today.get("isPartialBar") is True:
return True
if today.get("is_estimated") is True or today.get("isEstimated") is True:
return True
return bool(today.get("estimated_fields") or today.get("estimatedFields"))
def _today_looks_complete_daily_bar(
context: Dict[str, Any],
phase_context: Dict[str, Any],
) -> bool:
today = context.get("today")
if (
not isinstance(today, dict)
or today.get("close") in (None, "")
or _today_has_realtime_overlay(today)
):
return False
effective_date = phase_context.get("effective_daily_bar_date")
today_date = today.get("date") or today.get("trade_date") or context.get("date")
if effective_date and today_date and str(today_date) != str(effective_date):
return False
return True
def _phase_aware_quote_labels(context: Dict[str, Any]) -> Tuple[str, str]:
"""Choose Chinese quote-table labels that do not conflict with phase context."""
phase_context = context.get("market_phase_context")
if not isinstance(phase_context, dict):
return "今日行情", "收盘价"
phase = str(phase_context.get("phase") or "").strip()
if phase in {"premarket", "non_trading"}:
today = context.get("today")
if _today_looks_complete_daily_bar(context, phase_context):
return "上一完整交易日行情", "上一完整交易日收盘价"
if _today_has_realtime_overlay(today):
return "最新行情", "实时估算价"
if isinstance(today, dict) and today.get("close") not in (None, ""):
return "最新行情", "最新价"
return "今日行情", "收盘价"
if (
phase in {"intraday", "lunch_break", "closing_auction"}
and phase_context.get("is_partial_bar") is True
):
return "最新行情", "盘中估算价"
return "今日行情", "收盘价"
def _should_hide_regular_session_ohlc(context: Dict[str, Any]) -> bool:
phase_context = context.get("market_phase_context")
if not isinstance(phase_context, dict):
return False
phase = str(phase_context.get("phase") or "").strip()
return phase in {"premarket", "non_trading"} and not _today_looks_complete_daily_bar(
context,
phase_context,
)
def _legacy_market_group(stock_code: Any) -> str:
code = str(stock_code or "").strip()
if not code or code.lower() == "unknown":
return "unknown"
market = detect_market(code)
return market if market in {"cn", "hk", "us"} else "unknown"
def _legacy_audit_marker_specs(
context: Dict[str, Any],
*,
code: str,
stock_name: str,
report_language: str,
news_context: Optional[str],
analysis_context_pack_summary: Optional[str],
) -> List[Dict[str, Any]]:
markers: List[Dict[str, Any]] = []
def add(marker_name: str, value: Any) -> None:
if value is None:
return
text = str(value).strip()
if not text:
return
markers.append(
{
"marker_name": marker_name,
"message_role": "user",
"text": text,
}
)
add("stock_code", code)
add("stock_name", stock_name)
add("analysis_date", context.get("date"))
add("market_phase", "## Market Phase Context" if report_language in ("en", "ko") else "## 市场阶段上下文")
add("daily_market_context", "## Daily Market Context" if report_language in ("en", "ko") else "## 大盘环境摘要")
add("market_structure_context", "## Market Structure Context" if report_language in ("en", "ko") else "## 市场结构上下文")
add("analysis_context_pack", analysis_context_pack_summary)
add("quote", "## 📈 技术面数据")
add("news_context", "## 📰 舆情情报" if news_context else None)
return markers
class _LiteLLMStreamError(RuntimeError):
"""Internal error wrapper that records whether any text was streamed."""
def __init__(self, message: str, *, partial_received: bool = False):
super().__init__(message)
self.partial_received = partial_received
class _AllModelsFailedError(Exception):
"""Raised when every model in the fallback chain fails.
This includes both LLM call errors and JSON parse errors (when a
``response_validator`` is provided to :meth:`GeminiAnalyzer._call_litellm`).
The ``last_response_text`` attribute holds the raw text from the last model
that *did* return a response (but whose JSON could not be validated), so
callers can still attempt a best-effort text fallback.
``last_model``, ``last_provider`` and ``last_usage`` record the resolved
route identity and token usage from the last attempt so callers can persist
diagnostics even on fallback.
"""
def __init__(
self,
message: str,
*,
last_response_text: Optional[str] = None,
last_model: Optional[str] = None,
last_provider: Optional[str] = None,
last_usage: Optional[Dict[str, Any]] = None,
):
super().__init__(message)
self.last_response_text = last_response_text
self.last_model = last_model
self.last_provider = last_provider
self.last_usage = last_usage or {}
from src.utils.data_processing import normalize_report_signal_attribution
def check_content_integrity(
result: "AnalysisResult",
*,
require_phase_decision: bool = False,
) -> Tuple[bool, List[str]]:
"""
Check mandatory fields for report content integrity.
Returns (pass, missing_fields). Module-level for use by pipeline (agent weak mode).
Note:
- Required fields: missing → pass=False, added to missing_fields
- Optional fields (e.g., signal_attribution): missing → pass=True and are not added to missing_fields
"""
missing: List[str] = []
def _is_blank_text(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return not value.strip()
return True
def _is_invalid_risk_alerts(value: Any) -> bool:
return not isinstance(value, list)
def _is_invalid_stop_loss(value: Any) -> bool:
if value is None:
return True
if isinstance(value, (list, tuple, dict)):
return True
if isinstance(value, str):
return not value.strip()
return False
if result.sentiment_score is None:
missing.append("sentiment_score")
advice = result.operation_advice
if not advice or not isinstance(advice, str) or _is_blank_text(advice):
missing.append("operation_advice")
summary = result.analysis_summary
if not summary or not isinstance(summary, str) or _is_blank_text(summary):
missing.append("analysis_summary")
dash = result.dashboard if isinstance(result.dashboard, dict) else {}
core = dash.get("core_conclusion")
core = core if isinstance(core, dict) else {}
if _is_blank_text(core.get("one_sentence")):
missing.append("dashboard.core_conclusion.one_sentence")
intel = dash.get("intelligence")
intel = intel if isinstance(intel, dict) else None
if intel is None or _is_invalid_risk_alerts(intel.get("risk_alerts")):
missing.append("dashboard.intelligence.risk_alerts")
if result.decision_type in ("buy", "hold"):
battle = dash.get("battle_plan")
battle = battle if isinstance(battle, dict) else {}
sp = battle.get("sniper_points")
sp = sp if isinstance(sp, dict) else {}
stop_loss = sp.get("stop_loss")
if _is_invalid_stop_loss(stop_loss):
missing.append("dashboard.battle_plan.sniper_points.stop_loss")
if require_phase_decision:
phase_decision = dash.get("phase_decision")
phase_decision = phase_decision if isinstance(phase_decision, dict) else {}
if not isinstance(phase_decision.get("phase_context"), dict):
missing.append("dashboard.phase_decision.phase_context")
if _is_blank_text(phase_decision.get("action_window")):
missing.append("dashboard.phase_decision.action_window")
if _is_blank_text(phase_decision.get("immediate_action")):
missing.append("dashboard.phase_decision.immediate_action")
if not isinstance(phase_decision.get("watch_conditions"), list):
missing.append("dashboard.phase_decision.watch_conditions")
if _is_blank_text(phase_decision.get("next_check_time")):
missing.append("dashboard.phase_decision.next_check_time")
if _is_blank_text(phase_decision.get("confidence_reason")):
missing.append("dashboard.phase_decision.confidence_reason")
if not isinstance(phase_decision.get("data_limitations"), list):
missing.append("dashboard.phase_decision.data_limitations")
return len(missing) == 0, missing
def apply_placeholder_fill(result: "AnalysisResult", missing_fields: List[str]) -> None:
"""Fill missing mandatory fields with placeholders (in-place). Module-level for pipeline."""
def _is_blank_text(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return not value.strip()
return True
def _is_invalid_risk_alerts(value: Any) -> bool:
return not isinstance(value, list)
def _is_invalid_stop_loss(value: Any) -> bool:
if value is None:
return True
if isinstance(value, (list, tuple, dict)):
return True
if isinstance(value, str):
return not value.strip()
return False
report_language = normalize_report_language(getattr(result, "report_language", "zh"))
placeholder = get_placeholder_text(report_language)
phase_decision_placeholders = {
"dashboard.phase_decision.action_window": _localized_text(
report_language,
en="Model did not provide a phase action window",
zh="模型未提供阶段化行动窗口",
ko="모델이 단계별 행동 구간을 제공하지 않았습니다",
),
"dashboard.phase_decision.immediate_action": _localized_text(
report_language,
en="Model did not provide a phase-aware immediate action",
zh="模型未提供阶段化即时动作",
ko="모델이 단계 인식 즉시 동작을 제공하지 않았습니다",
),
"dashboard.phase_decision.next_check_time": _localized_text(
report_language,
en="Model did not provide a next check point",
zh="模型未提供下一次检查点",
ko="모델이 다음 점검 시점을 제공하지 않았습니다",
),
"dashboard.phase_decision.confidence_reason": _localized_text(
report_language,
en="Model did not provide a phase confidence rationale",
zh="模型未提供阶段化置信度理由",
ko="모델이 단계별 신뢰도 근거를 제공하지 않았습니다",
),
}
for field in missing_fields:
if field == "sentiment_score":
result.sentiment_score = 50
elif field == "operation_advice":
if _is_blank_text(result.operation_advice):
result.operation_advice = placeholder
elif field == "analysis_summary":
if _is_blank_text(result.analysis_summary):
result.analysis_summary = placeholder
elif field == "dashboard.core_conclusion.one_sentence":
if not result.dashboard:
result.dashboard = {}
core = result.dashboard.get("core_conclusion")
if not isinstance(core, dict):
core = {}
result.dashboard["core_conclusion"] = core
fallback_sentence = (
result.analysis_summary
or result.operation_advice
or placeholder
)
if _is_blank_text(core.get("one_sentence")):
result.dashboard["core_conclusion"]["one_sentence"] = fallback_sentence
elif field == "dashboard.intelligence.risk_alerts":
if not result.dashboard:
result.dashboard = {}
intelligence = result.dashboard.get("intelligence")
if not isinstance(intelligence, dict):
intelligence = {}
result.dashboard["intelligence"] = intelligence
if _is_invalid_risk_alerts(intelligence.get("risk_alerts")):
risk_warning_values = _normalize_risk_warning_values(result.risk_warning)
intelligence["risk_alerts"] = risk_warning_values
elif field == "dashboard.battle_plan.sniper_points.stop_loss":
if not result.dashboard:
result.dashboard = {}
battle_plan = result.dashboard.get("battle_plan")
if not isinstance(battle_plan, dict):
battle_plan = {}
result.dashboard["battle_plan"] = battle_plan
sniper_points = battle_plan.get("sniper_points")
if not isinstance(sniper_points, dict):
sniper_points = {}
battle_plan["sniper_points"] = sniper_points
if _is_invalid_stop_loss(sniper_points.get("stop_loss")):
sniper_points["stop_loss"] = placeholder
elif field.startswith("dashboard.phase_decision."):
if not result.dashboard:
result.dashboard = {}
phase_decision = result.dashboard.get("phase_decision")
if not isinstance(phase_decision, dict):
phase_decision = {}
result.dashboard["phase_decision"] = phase_decision
if field == "dashboard.phase_decision.phase_context":
if not isinstance(phase_decision.get("phase_context"), dict):
phase_decision["phase_context"] = {}
elif field == "dashboard.phase_decision.watch_conditions":
if not isinstance(phase_decision.get("watch_conditions"), list):
phase_decision["watch_conditions"] = []
elif field == "dashboard.phase_decision.data_limitations":
if not isinstance(phase_decision.get("data_limitations"), list):
phase_decision["data_limitations"] = []
elif field in phase_decision_placeholders:
if _is_blank_text(phase_decision.get(field.rsplit(".", 1)[-1])):
phase_decision[field.rsplit(".", 1)[-1]] = phase_decision_placeholders[field]
# ---------- chip_structure fallback (Issue #589) ----------
_CHIP_KEYS: tuple = ("profit_ratio", "avg_cost", "concentration", "chip_health")
def _is_value_placeholder(v: Any) -> bool:
"""True if value is empty or placeholder (N/A, 数据缺失, etc.)."""
return is_chip_placeholder_value(v)
_RISK_WARNING_PLACEHOLDER_TEXTS = {
"",
"n/a",
"na",
"none",
"null",
"unknown",
"tbd",
"暂无",
"待补充",
"数据缺失",
"未知",
"",
}
_STRUCTURAL_RISK_PHRASE_HINTS = (
"重大利空",
"重大风险",
"关键风险",
"减持",
"高位减持",
"退市",
"退市风险",
"停牌",
"重大问询",
"处罚",
"限售",
"违规",
"违规风险",
"诉讼",
"问询",
"监管",
"财务",
"审计",
"爆雷",
"暴雷",
"违约",
"违约风险",
"流动性危机",
"债务",
"清算",
"破产",
"重大变脸",
"major risk",
"material adverse",
"suspension",
"delisting",
"regulatory",
"downgrade",
"liquidity",
"default",
)
_CAPITAL_FLOW_UNAVAILABLE_STATUS = {
"not_supported",
"not supported",
"unsupported",
"unavailable",
"not_available",
"not available",
"none",
"na",
"n/a",
"null",
"missing",
}
def _is_meaningful_text(value: Any) -> bool:
text = str(value).strip() if value is not None else ""
if not text:
return False
lowered = text.strip().lower()
return lowered not in _RISK_WARNING_PLACEHOLDER_TEXTS
def _safe_float(v: Any, default: float = 0.0) -> float:
"""Safely convert to float; return default on failure. Private helper for chip fill."""
if v is None:
return default
if isinstance(v, (int, float)):
try:
return default if math.isnan(float(v)) else float(v)
except (ValueError, TypeError):
return default
try:
return float(str(v).strip())
except (TypeError, ValueError):
return default
def _coerce_chip_metric(v: Any) -> Optional[float]:
"""Convert chip metrics while preserving the distinction between missing and zero."""
if v is None:
return None
try:
numeric = float(v)
except (TypeError, ValueError):
try:
numeric = float(str(v).strip())
except (TypeError, ValueError):
return None
return None if math.isnan(numeric) else numeric
_BULLISH_TREND_HINTS: Tuple[str, ...] = (
"多头排列",
"持续上涨",
"趋势向上",
"上升趋势",
"向上发散",
"bullish",
"uptrend",
)
_WEAK_BULLISH_TREND_HINTS: Tuple[str, ...] = ("弱势多头",)
_BEARISH_TREND_HINTS: Tuple[str, ...] = (
"空头排列",
"持续下跌",
"趋势向下",
"下降趋势",
"向下发散",
"bearish",
"downtrend",
)
_WEAK_BEARISH_TREND_HINTS: Tuple[str, ...] = ("弱势空头",)
_NEGATION_TOKENS: Tuple[str, ...] = (
"不是",
"并非",
"并未",
"没有",
"尚不",
"尚未",
"",
"",
"不属",
"",
"not ",
"no ",
)
_NEGATION_BREAK_CHARS: Tuple[str, ...] = (",", ".", ";", ":", "!", "?", "", "", "", "", "", "", "\n")
_NEGATION_LOOKBACK_CHARS = 16
_NEGATION_MAX_GAP_CHARS = 8
_NEGATION_SCOPE_BREAK_TOKENS: Tuple[str, ...] = (
"而是",
"但是",
"",
"反而",
"反倒",
"转为",
"转成",
"改为",
"改成",
" but ",
" instead ",
" rather ",
)
_SINGLE_CHAR_NEGATION_GAP_PREFIXES: Tuple[str, ...] = (
"形成",
"出现",
"进入",
"转为",
"转成",
"构成",
"呈现",
"显示",
"属于",
"",
"",
"",
"",
"",
"",
"",
)
def _normalize_prompt_reason_items(items: Any) -> List[str]:
"""Normalize prompt reason/risk items into a clean string list."""
if not isinstance(items, list):
return []
normalized: List[str] = []
for item in items:
text = str(item).strip()
if text:
normalized.append(text)
return normalized
def _contains_trend_hint(text: str, hints: Tuple[str, ...]) -> bool:
"""Return True when text contains a non-negated strong trend hint."""
lowered = text.strip().lower()
def _has_negation_scope_break(gap: str) -> bool:
normalized_gap = gap.lower()
for token in _NEGATION_SCOPE_BREAK_TOKENS:
token_index = normalized_gap.find(token)
if token_index > 0:
return True
return False
def _is_valid_negation_gap(token: str, gap: str) -> bool:
if not gap:
return True
if token not in {"", "", ""}:
return True
return any(gap.startswith(prefix) for prefix in _SINGLE_CHAR_NEGATION_GAP_PREFIXES)
def _is_negated_match(index: int) -> bool:
prefix = lowered[max(0, index - _NEGATION_LOOKBACK_CHARS):index]
for token in _NEGATION_TOKENS:
token_index = prefix.rfind(token)
if token_index < 0:
continue
gap = prefix[token_index + len(token):]
if any(char in gap for char in _NEGATION_BREAK_CHARS):
continue
stripped_gap = gap.strip()
if len(stripped_gap) > _NEGATION_MAX_GAP_CHARS:
continue
if _has_negation_scope_break(stripped_gap):
continue
if not _is_valid_negation_gap(token, stripped_gap):
continue
return True
return False
for hint in hints:
keyword = hint.lower()
start = 0
while True:
index = lowered.find(keyword, start)
if index < 0:
break
if not _is_negated_match(index):
return True
start = index + len(keyword)
return False
def _infer_trend_direction(trend: Dict[str, Any]) -> str:
"""Infer the final trend direction from trend_status and ma_alignment."""
combined = " ".join(
str(trend.get(key, "")).strip()
for key in ("trend_status", "ma_alignment")
if str(trend.get(key, "")).strip()
)
if not combined:
return "neutral"
lowered = combined.lower()
normalized = lowered.replace(" ", "")
has_bullish = (
_contains_trend_hint(combined, _BULLISH_TREND_HINTS + _WEAK_BULLISH_TREND_HINTS)
or "ma5>ma10>ma20" in normalized
or (
"ma5>ma10" in normalized
and any(pattern in normalized for pattern in ("ma10≤ma20", "ma10<=ma20"))
)
)
has_bearish = (
_contains_trend_hint(combined, _BEARISH_TREND_HINTS + _WEAK_BEARISH_TREND_HINTS)
or "ma5<ma10<ma20" in normalized
or (
"ma5<ma10" in normalized
and any(pattern in normalized for pattern in ("ma10≥ma20", "ma10>=ma20"))
)
)
if has_bullish and not has_bearish:
return "bullish"
if has_bearish and not has_bullish:
return "bearish"
return "neutral"
def _filter_conflicting_trend_items(items: List[str], conflict_hints: Tuple[str, ...]) -> List[str]:
"""Drop reasons that directly conflict with the final trend direction."""
return [item for item in items if not _contains_trend_hint(item, conflict_hints)]
def _sanitize_trend_analysis_for_prompt(
trend: Any,
*,
volume_change_ratio: Any = None,
) -> Dict[str, Any]:
"""Clean prompt-only trend hints on a derived copy without touching runtime/provider config."""
trend_dict = dict(trend) if isinstance(trend, dict) else {}
signal_reasons = _normalize_prompt_reason_items(trend_dict.get("signal_reasons"))
risk_factors = _normalize_prompt_reason_items(trend_dict.get("risk_factors"))
prompt_notes: List[str] = []
trend_direction = _infer_trend_direction(trend_dict)
if trend_direction == "bearish":
filtered_signal_reasons = _filter_conflicting_trend_items(
signal_reasons,
_BULLISH_TREND_HINTS + _WEAK_BULLISH_TREND_HINTS,
)
if len(filtered_signal_reasons) != len(signal_reasons):
prompt_notes.append("当前技术结构偏空,已剔除与空头主判断直接冲突的看多结构理由。")
signal_reasons = filtered_signal_reasons
prompt_notes.append(
"若新闻、业绩或政策催化偏多,只能表述为“事件先行、技术待确认”或“基本面偏多,但技术面尚未确认”,严禁写成确定性买点。"
)
elif trend_direction == "bullish":
filtered_signal_reasons = _filter_conflicting_trend_items(
signal_reasons,
_BEARISH_TREND_HINTS + _WEAK_BEARISH_TREND_HINTS,
)
if len(filtered_signal_reasons) != len(signal_reasons):
prompt_notes.append("当前技术结构偏多,已剔除与多头主判断直接冲突的空头结构理由。")
signal_reasons = filtered_signal_reasons
filtered_risk_factors = _filter_conflicting_trend_items(
risk_factors,
_BEARISH_TREND_HINTS + _WEAK_BEARISH_TREND_HINTS,
)
if len(filtered_risk_factors) != len(risk_factors):
prompt_notes.append("当前技术结构偏多,已剔除与多头主判断直接冲突的空头结构风险表述。")
risk_factors = filtered_risk_factors
parsed_volume_change = _safe_float(volume_change_ratio, default=math.nan)
if math.isfinite(parsed_volume_change) and parsed_volume_change > 10:
prompt_notes.append(
f"成交量较昨日变化约 {parsed_volume_change:.2f} 倍,可能存在异常数据或一次性冲量;量能信号必须降权解读,不能机械视为强确认。"
)
trend_dict["signal_reasons"] = signal_reasons
trend_dict["risk_factors"] = risk_factors
trend_dict["prompt_consistency_notes"] = prompt_notes
trend_dict["prompt_trend_direction"] = trend_direction
return trend_dict
def _derive_chip_health(profit_ratio: float, concentration_90: float, language: str = "zh") -> str:
"""Derive chip_health from profit_ratio and concentration_90."""
if profit_ratio >= 0.9:
return localize_chip_health("警惕", language) # 获利盘极高
if concentration_90 >= 0.25:
return localize_chip_health("警惕", language) # 筹码分散
if concentration_90 < 0.15 and 0.3 <= profit_ratio < 0.9:
return localize_chip_health("健康", language) # 集中且获利比例适中
return localize_chip_health("一般", language)
def _build_chip_structure_from_data(chip_data: Any, language: str = "zh") -> Dict[str, Any]:
"""Build chip_structure dict from ChipDistribution or dict."""
if hasattr(chip_data, "profit_ratio"):
pr = _safe_float(chip_data.profit_ratio)
ac = chip_data.avg_cost
c90 = _safe_float(chip_data.concentration_90)
else:
d = chip_data if isinstance(chip_data, dict) else {}
pr = _safe_float(d.get("profit_ratio"))
ac = d.get("avg_cost")
c90 = _safe_float(d.get("concentration_90"))
chip_health = _derive_chip_health(pr, c90, language=language)
return {
"profit_ratio": f"{pr:.1%}",
"avg_cost": ac if (ac is not None and _safe_float(ac) != 0.0) else "N/A",
"concentration": f"{c90:.2%}",
"chip_health": chip_health,
}
def _has_meaningful_chip_data(chip_data: Any) -> bool:
"""Return True when chip data has the core metrics required for reporting."""
if not chip_data:
return False
if hasattr(chip_data, "avg_cost"):
avg_cost = _coerce_chip_metric(getattr(chip_data, "avg_cost", None))
concentration_90 = _coerce_chip_metric(getattr(chip_data, "concentration_90", None))
concentration_70 = _coerce_chip_metric(getattr(chip_data, "concentration_70", None))
else:
d = chip_data if isinstance(chip_data, dict) else {}
avg_cost = _coerce_chip_metric(d.get("avg_cost"))
concentration_90_value = d.get("concentration_90")
if concentration_90_value is None:
concentration_90_value = d.get("concentration")
concentration_90 = _coerce_chip_metric(concentration_90_value)
concentration_70 = _coerce_chip_metric(d.get("concentration_70"))
return (
avg_cost is not None
and avg_cost > 0
and (
(concentration_90 is not None and concentration_90 >= 0)
or (concentration_70 is not None and concentration_70 >= 0)
)
)
def _mark_chip_structure_unavailable(result: "AnalysisResult", language: str) -> None:
if not result or not isinstance(result.dashboard, dict):
return
data_perspective = result.dashboard.get("data_perspective")
if not isinstance(data_perspective, dict):
return
data_perspective["chip_structure"] = {}
data_perspective["chip_unavailable_reason"] = get_chip_unavailable_text(language)
def normalize_chip_structure_availability(result: "AnalysisResult", chip_data: Any) -> None:
"""Fill valid chip metrics or collapse placeholder-only chip fields to one fallback line."""
if not result:
return
language = getattr(result, "report_language", "zh")
if _has_meaningful_chip_data(chip_data):
fill_chip_structure_if_needed(result, chip_data)
return
_mark_chip_structure_unavailable(result, language)
def fill_chip_structure_if_needed(result: "AnalysisResult", chip_data: Any) -> None:
"""When chip_data exists, fill chip_structure placeholder fields from chip_data (in-place)."""
if not result or not _has_meaningful_chip_data(chip_data):
return
try:
if not result.dashboard:
result.dashboard = {}
dash = result.dashboard
# Use `or {}` rather than setdefault so that an explicit `null` from LLM is also replaced
dp = dash.get("data_perspective") or {}
dash["data_perspective"] = dp
cs = dp.get("chip_structure") or {}
filled = _build_chip_structure_from_data(
chip_data,
language=getattr(result, "report_language", "zh"),
)
# Start from a copy of cs to preserve any extra keys the LLM may have added
merged = dict(cs)
for k in _CHIP_KEYS:
if _is_value_placeholder(merged.get(k)):
merged[k] = filled[k]
if merged != cs:
dp["chip_structure"] = merged
logger.info("[chip_structure] Filled placeholder chip fields from data source (Issue #589)")
except Exception as e:
logger.warning("[chip_structure] Fill failed, skipping: %s", e)
_PRICE_POS_KEYS = ("ma5", "ma10", "ma20", "bias_ma5", "bias_status", "current_price", "support_level", "resistance_level")
def fill_price_position_if_needed(
result: "AnalysisResult",
trend_result: Any = None,
realtime_quote: Any = None,
) -> None:
"""Fill missing price_position fields from trend_result / realtime data (in-place)."""
if not result:
return
try:
if not result.dashboard:
result.dashboard = {}
dash = result.dashboard
dp = dash.get("data_perspective") or {}
dash["data_perspective"] = dp
pp = dp.get("price_position") or {}
computed: Dict[str, Any] = {}
if trend_result:
tr = trend_result if isinstance(trend_result, dict) else (
trend_result.__dict__ if hasattr(trend_result, "__dict__") else {}
)
computed["ma5"] = tr.get("ma5")
computed["ma10"] = tr.get("ma10")
computed["ma20"] = tr.get("ma20")
computed["bias_ma5"] = tr.get("bias_ma5")
computed["current_price"] = tr.get("current_price")
support_levels = tr.get("support_levels") or []
resistance_levels = tr.get("resistance_levels") or []
if support_levels:
computed["support_level"] = support_levels[0]
if resistance_levels:
computed["resistance_level"] = resistance_levels[0]
if realtime_quote:
rq = realtime_quote if isinstance(realtime_quote, dict) else (
realtime_quote.to_dict() if hasattr(realtime_quote, "to_dict") else {}
)
if _is_value_placeholder(computed.get("current_price")):
computed["current_price"] = rq.get("price")
filled = False
for k in _PRICE_POS_KEYS:
if _is_value_placeholder(pp.get(k)) and not _is_value_placeholder(computed.get(k)):
pp[k] = computed[k]
filled = True
if filled:
dp["price_position"] = pp
logger.info("[price_position] Filled placeholder fields from computed data")
except Exception as e:
logger.warning("[price_position] Fill failed, skipping: %s", e)
def stabilize_decision_with_structure(
result: "AnalysisResult",
trend_result: Any = None,
fundamental_context: Optional[Dict[str, Any]] = None,
) -> None:
"""
Calibrate aggressive buy/sell advice with price levels and capital flow.
The LLM can overreact to one-day price movement. This guard keeps the
public `decision_type` enum stable while allowing richer neutral wording
such as 震荡/洗盘观察 when support, resistance, and fund flow do not confirm
an immediate buy/sell action.
"""
if not result:
return
try:
language = normalize_report_language(getattr(result, "report_language", "zh"))
dashboard = result.dashboard if isinstance(result.dashboard, dict) else {}
data_perspective = dashboard.get("data_perspective") if isinstance(dashboard, dict) else {}
if not isinstance(data_perspective, dict):
data_perspective = {}
price_position = data_perspective.get("price_position")
if not isinstance(price_position, dict):
price_position = {}
trend_dict = _as_dict_for_decision_guard(trend_result)
current_price = _first_numeric_value(
getattr(result, "current_price", None),
price_position.get("current_price"),
trend_dict.get("current_price"),
)
support = _first_numeric_value(
price_position.get("support_level"),
_first_list_value(trend_dict.get("support_levels")),
)
resistance = _first_numeric_value(
price_position.get("resistance_level"),
_first_list_value(trend_dict.get("resistance_levels")),
)
decision_type = infer_decision_type_from_advice(
getattr(result, "decision_type", ""),
default=getattr(result, "decision_type", "hold") or "hold",
)
decision_type = decision_type if decision_type in {"buy", "hold", "sell"} else "hold"
advice_decision_type = infer_decision_type_from_advice(
getattr(result, "operation_advice", ""),
default="",
)
flow_bias, flow_reason = _capital_flow_bias_with_status(fundamental_context)
if flow_bias == "unavailable":
if isinstance(fundamental_context, dict) and "capital_flow" in fundamental_context:
if decision_type == "buy" or advice_decision_type == "buy":
_downgrade_buy_without_capital_flow(
result,
language,
current_price=current_price,
support=support,
resistance=resistance,
flow_status=flow_reason,
)
else:
_set_decision_stability_unavailable(
result,
language,
current_price=current_price,
support=support,
resistance=resistance,
flow_status=flow_reason,
)
return
if current_price is None:
return
broke_support = support is not None and current_price < support * 0.985
near_support = support is not None and not broke_support and current_price <= support * 1.03
breakout = resistance is not None and current_price > resistance * 1.01
near_resistance = (
resistance is not None
and not breakout
and current_price >= resistance * 0.97
)
mid_range = (
support is not None
and resistance is not None
and support * 1.03 < current_price < resistance * 0.97
)
has_significant_risk = _has_structural_risk_alert(result)
if decision_type == "buy":
if near_resistance and flow_bias != "inflow":
_downgrade_to_structural_hold(
result,
language,
advice_key="range",
reason_key="buy_near_resistance",
current_price=current_price,
support=support,
resistance=resistance,
flow_bias=flow_bias,
)
elif flow_bias == "outflow" and not breakout:
_downgrade_to_structural_hold(
result,
language,
advice_key="range",
reason_key="buy_with_outflow",
current_price=current_price,
support=support,
resistance=resistance,
flow_bias=flow_bias,
)
elif mid_range and flow_bias == "neutral":
_downgrade_to_structural_hold(
result,
language,
advice_key="range",
reason_key="hold_mid_range",
current_price=current_price,
support=support,
resistance=resistance,
flow_bias=flow_bias,
)
elif decision_type == "sell":
if near_support and (flow_bias != "outflow") and not has_significant_risk:
_downgrade_to_structural_hold(
result,
language,
advice_key="shakeout",
reason_key="sell_near_support",
current_price=current_price,
support=support,
resistance=resistance,
flow_bias=flow_bias,
)
elif flow_bias == "inflow" and not broke_support and not has_significant_risk:
_downgrade_to_structural_hold(
result,
language,
advice_key="hold",
reason_key="sell_with_inflow",
current_price=current_price,
support=support,
resistance=resistance,
flow_bias=flow_bias,
)
elif decision_type == "hold":
change_pct = _first_numeric_value(getattr(result, "change_pct", None))
if change_pct is not None and change_pct < 0 and near_support and flow_bias != "outflow":
_set_structural_hold_wording(
result,
language,
advice_key="shakeout",
reason_key="hold_shakeout",
current_price=current_price,
support=support,
resistance=resistance,
flow_bias=flow_bias,
)
elif mid_range and flow_bias == "neutral":
_set_structural_hold_wording(
result,
language,
advice_key="range",
reason_key="hold_mid_range",
current_price=current_price,
support=support,
resistance=resistance,
flow_bias=flow_bias,
)
_sync_stability_dashboard_fields(result)
except Exception as exc:
logger.warning("[decision_stability] skipped: %s", exc)
def _has_structural_risk_alert(result: "AnalysisResult") -> bool:
dashboard = result.dashboard if isinstance(result.dashboard, dict) else {}
risk_text = getattr(result, "risk_warning", "")
if _is_significant_structural_risk(risk_text):
return True
intelligence = dashboard.get("intelligence") if isinstance(dashboard, dict) else None
if isinstance(intelligence, dict):
risk_alerts = intelligence.get("risk_alerts")
if isinstance(risk_alerts, str):
if _is_significant_structural_risk(risk_alerts):
return True
elif isinstance(risk_alerts, (list, tuple, set)):
if any(_is_significant_structural_risk(item) for item in risk_alerts):
return True
core_conclusion = dashboard.get("core_conclusion") if isinstance(dashboard, dict) else None
if isinstance(core_conclusion, dict):
signal_type = str(core_conclusion.get("signal_type", "")).strip()
if _is_significant_structural_risk(signal_type):
return True
return False
def _is_significant_structural_risk(value: Any) -> bool:
text = str(value or "").strip()
if not _is_meaningful_text(text):
return False
normalized = text.lower()
if any(keyword in normalized for keyword in _STRUCTURAL_RISK_PHRASE_HINTS):
return True
return "重大" in text and "风险" in normalized
def _sync_stability_dashboard_fields(result: "AnalysisResult") -> None:
dashboard = result.dashboard if isinstance(result.dashboard, dict) else {}
result.dashboard = dashboard
dashboard["sentiment_score"] = getattr(result, "sentiment_score", None)
dashboard["operation_advice"] = getattr(result, "operation_advice", None)
dashboard["decision_type"] = getattr(result, "decision_type", None)
def _as_dict_for_decision_guard(value: Any) -> Dict[str, Any]:
if isinstance(value, dict):
return value
if hasattr(value, "to_dict"):
try:
converted = value.to_dict()
return converted if isinstance(converted, dict) else {}
except Exception:
return {}
if hasattr(value, "__dict__"):
return dict(value.__dict__)
return {}
def _first_list_value(value: Any) -> Any:
if isinstance(value, (list, tuple)) and value:
return value[0]
return value
def _coerce_numeric_value(value: Any) -> Optional[float]:
if isinstance(value, bool) or value is None:
return None
if isinstance(value, (int, float)):
if math.isfinite(float(value)):
return float(value)
return None
text = str(value).replace(",", "").replace("", "").strip()
if not text or text.upper() in {"N/A", "NA", "NONE", "NULL"}:
return None
match = re.search(r"[-+]?\d+(?:\.\d+)?", text)
if not match:
return None
try:
return float(match.group(0))
except ValueError:
return None
def _first_numeric_value(*values: Any) -> Optional[float]:
for value in values:
if isinstance(value, (list, tuple)):
nested = _first_numeric_value(*value)
if nested is not None:
return nested
continue
numeric = _coerce_numeric_value(value)
if numeric is not None:
return numeric
return None
def _capital_flow_bias(fundamental_context: Optional[Dict[str, Any]]) -> str:
return _capital_flow_bias_with_status(fundamental_context)[0]
def _capital_flow_bias_with_status(
fundamental_context: Optional[Dict[str, Any]],
) -> tuple[str, str]:
if not isinstance(fundamental_context, dict):
return "unavailable", "invalid_context"
block = fundamental_context.get("capital_flow")
if not isinstance(block, dict):
return "unavailable", "capital_flow_block_missing"
status = str(block.get("status") or "").strip().lower()
normalized_status = status.replace("-", " ").replace("_", " ").strip()
if normalized_status in _CAPITAL_FLOW_UNAVAILABLE_STATUS or "not supported" in normalized_status:
return "unavailable", status or "not_supported"
data = block.get("data") if isinstance(block.get("data"), dict) else block
stock_flow = data.get("stock_flow") if isinstance(data, dict) else None
if not isinstance(stock_flow, dict) or not stock_flow:
return "unavailable", "empty_stock_flow"
def _flow_direction(value: Optional[float]) -> Optional[str]:
if value is None or value == 0:
return None
return "inflow" if value > 0 else "outflow"
numeric_values = [
_coerce_numeric_value(stock_flow.get("main_net_inflow")),
_coerce_numeric_value(stock_flow.get("inflow_5d")),
_coerce_numeric_value(stock_flow.get("inflow_10d")),
]
if all(value is None for value in numeric_values):
return "unavailable", "missing_or_na_flow_fields"
ordered_signals = [
_flow_direction(value) for value in numeric_values
]
directions = {signal for signal in ordered_signals if signal is not None}
if not directions or len(directions) > 1:
return "neutral", "conflict_or_missing"
for signal in ordered_signals:
if signal is not None:
return signal, "ok"
return "neutral", "neutral"
def _capital_flow_status_for_stability(reason: str, language: str) -> str:
normalized = str(reason or "").strip().lower()
if "not_supported" in normalized or "unsupported" in normalized or "not available" in normalized:
return "市场资金流服务暂不支持" if language == "zh" else "Capital flow source unsupported"
if "empty_stock_flow" in normalized or "missing" in normalized:
return "资金流数据缺失" if language == "zh" else "capital flow data unavailable"
return "资金流数据不可用" if language == "zh" else "capital flow unavailable"
def _set_decision_stability_unavailable(
result: "AnalysisResult",
language: str,
*,
current_price: Optional[float],
support: Optional[float],
resistance: Optional[float],
flow_status: str,
) -> None:
dashboard = result.dashboard if isinstance(result.dashboard, dict) else {}
result.dashboard = dashboard
dashboard["decision_stability"] = {
"applied": False,
"reason": "资金流不可用,未使用资金流校准" if language == "zh" else "Capital flow unavailable; stability calibration not applied",
"capital_flow_status": _capital_flow_status_for_stability(flow_status, language),
"current_price": current_price,
"support": support,
"resistance": resistance,
"capital_flow_bias": "unavailable",
}
_sync_stability_dashboard_fields(result)
def _record_decision_score_calibration(
result: "AnalysisResult",
*,
raw_score: int,
adjusted_score: int,
final_action: str,
guardrail_reason: Optional[str],
) -> None:
dashboard = result.dashboard if isinstance(result.dashboard, dict) else {}
result.dashboard = dashboard
calibration = score_band_metadata(raw_score)
calibration.update(
{
"raw_score": raw_score,
"adjusted_score": adjusted_score,
"final_action": final_action,
}
)
if guardrail_reason:
calibration["guardrail_reason"] = guardrail_reason
dashboard["decision_score_calibration"] = calibration
def _bound_hold_watch_sentiment_score(
result: "AnalysisResult",
*,
reason: Optional[str] = None,
final_action: str = "watch",
) -> None:
try:
score = int(getattr(result, "sentiment_score", 50))
except (TypeError, ValueError):
score = 50
adjusted_score = min(59, max(45, score))
result.sentiment_score = adjusted_score
_record_decision_score_calibration(
result,
raw_score=score,
adjusted_score=adjusted_score,
final_action=final_action,
guardrail_reason=reason,
)
def _apply_hold_watch_dashboard(
result: "AnalysisResult",
language: str,
*,
advice: str,
reason: str,
current_price: Optional[float],
support: Optional[float],
resistance: Optional[float],
flow_bias: str,
no_position: str,
has_position: str,
capital_flow_status: Optional[str] = None,
) -> None:
result.operation_advice = advice
dashboard = result.dashboard if isinstance(result.dashboard, dict) else {}
result.dashboard = dashboard
core = dashboard.get("core_conclusion")
if not isinstance(core, dict):
core = {}
dashboard["core_conclusion"] = core
core["signal_type"] = "🟡持有观望" if language == "zh" else "🟡 Hold / Watch"
core["one_sentence"] = f"{advice}{reason}" if language == "zh" else f"{advice}: {reason}"
position_advice = core.get("position_advice")
if not isinstance(position_advice, dict):
position_advice = {}
core["position_advice"] = position_advice
position_advice["no_position"] = no_position
position_advice["has_position"] = has_position
stability = {
"applied": True,
"reason": reason,
"current_price": current_price,
"support": support,
"resistance": resistance,
"capital_flow_bias": flow_bias,
}
if capital_flow_status is not None:
stability["capital_flow_status"] = capital_flow_status
score_calibration = dashboard.get("decision_score_calibration")
if isinstance(score_calibration, dict):
stability["raw_score"] = score_calibration.get("raw_score")
stability["adjusted_score"] = score_calibration.get("adjusted_score")
stability["final_action"] = score_calibration.get("final_action")
dashboard["decision_stability"] = stability
if reason and reason not in str(result.risk_warning or ""):
sep = "" if language == "zh" else "; "
result.risk_warning = f"{result.risk_warning}{sep}{reason}" if result.risk_warning else reason
result.buy_reason = reason or result.buy_reason
def _downgrade_buy_without_capital_flow(
result: "AnalysisResult",
language: str,
*,
current_price: Optional[float],
support: Optional[float],
resistance: Optional[float],
flow_status: str,
) -> None:
status_text = _capital_flow_status_for_stability(flow_status, language)
if language == "zh":
advice = "持有观察"
reason = f"{status_text},买入结论缺少资金面确认,先按观察处理。"
no_position = "空仓先不追买,等待资金流恢复、支撑确认或有效突破后再行动。"
has_position = "持仓以关键支撑为风控线,资金流恢复前控制仓位。"
confidence = ""
else:
advice = "Hold and watch"
reason = f"{status_text}; the buy call lacks capital-flow confirmation, so treat it as watch-only."
no_position = "Do not chase; wait for capital-flow recovery, support confirmation, or a valid breakout."
has_position = "Use key support as the risk line and keep position size controlled until capital flow recovers."
confidence = "Low"
result.decision_type = "hold"
result.confidence_level = confidence
_bound_hold_watch_sentiment_score(result, reason=reason, final_action="hold")
_apply_hold_watch_dashboard(
result,
language,
advice=advice,
reason=reason,
current_price=current_price,
support=support,
resistance=resistance,
flow_bias="unavailable",
no_position=no_position,
has_position=has_position,
capital_flow_status=status_text,
)
_sync_stability_dashboard_fields(result)
logger.info("[decision_stability] Downgraded buy because capital flow is unavailable: %s", flow_status)
def _downgrade_to_structural_hold(
result: "AnalysisResult",
language: str,
*,
advice_key: str,
reason_key: str,
current_price: float,
support: Optional[float],
resistance: Optional[float],
flow_bias: str,
) -> None:
result.decision_type = "hold"
_set_structural_hold_wording(
result,
language,
advice_key=advice_key,
reason_key=reason_key,
current_price=current_price,
support=support,
resistance=resistance,
flow_bias=flow_bias,
calibrate_score=True,
)
def _set_structural_hold_wording(
result: "AnalysisResult",
language: str,
*,
advice_key: str,
reason_key: str,
current_price: float,
support: Optional[float],
resistance: Optional[float],
flow_bias: str,
calibrate_score: bool = False,
) -> None:
advice_map = {
"zh": {
"range": "震荡观望",
"shakeout": "洗盘观察",
"hold": "持有观察",
},
"en": {
"range": "Range-bound watch",
"shakeout": "Shakeout watch",
"hold": "Hold and watch",
},
"ko": {
"range": "박스권 관망",
"shakeout": "흔들기 관찰",
"hold": "보유 관찰",
},
}
advice_default = {"zh": "持有观察", "en": "Hold and watch", "ko": "보유 관찰"}.get(language, "Hold and watch")
advice = advice_map.get(language, advice_map["en"]).get(advice_key, advice_default)
reason_templates = {
"zh": {
"buy_near_resistance": "价格接近压力位且主力资金未确认流入,不宜仅因短线反弹追买。",
"buy_with_outflow": "主力资金流出与买入结论冲突,买点需等待支撑确认或资金回流。",
"sell_near_support": "价格贴近支撑且未见资金持续流出,不宜仅因单日下跌直接卖出。",
"sell_with_inflow": "主力资金流入与卖出结论冲突,先按持有观察处理并跟踪支撑失效。",
"hold_shakeout": "价格回落至支撑附近但资金未确认流出,更适合按洗盘观察处理。",
"hold_mid_range": "价格处于支撑与压力之间且资金流不明确,维持震荡观望更可操作。",
},
"en": {
"buy_near_resistance": "Price is near resistance without confirmed main-force inflow, so chasing the rebound is not actionable.",
"buy_with_outflow": "Main-force outflow conflicts with a buy call; wait for support confirmation or capital inflow.",
"sell_near_support": "Price is near support without sustained outflow, so a one-day drop is not enough to sell.",
"sell_with_inflow": "Main-force inflow conflicts with a sell call; hold and watch for support failure.",
"hold_shakeout": "Price pulled back near support without confirmed outflow, which is better treated as a shakeout watch.",
"hold_mid_range": "Price is between support and resistance with neutral fund flow, so range-bound watch is more actionable.",
},
"ko": {
"buy_near_resistance": "가격이 저항선에 근접했고 주력 자금 유입이 확인되지 않아 단기 반등만 보고 추격 매수하기 어렵습니다.",
"buy_with_outflow": "주력 자금 유출이 매수 결론과 상충하므로 지지 확인이나 자금 재유입을 기다려야 합니다.",
"sell_near_support": "가격이 지지선에 근접했고 지속적 유출이 없어 하루 하락만으로 매도하기 어렵습니다.",
"sell_with_inflow": "주력 자금 유입이 매도 결론과 상충하므로 우선 보유 관찰하며 지지 이탈을 추적합니다.",
"hold_shakeout": "가격이 지지선 부근까지 눌렸지만 유출이 확인되지 않아 흔들기 관찰로 처리하는 것이 적절합니다.",
"hold_mid_range": "가격이 지지선과 저항선 사이이고 자금 흐름이 불명확해 박스권 관망이 더 실행 가능합니다.",
},
}
reason = reason_templates.get(language, reason_templates["en"]).get(reason_key, "")
if calibrate_score:
final_action = "watch" if advice_key in {"range", "shakeout"} else "hold"
_bound_hold_watch_sentiment_score(result, reason=reason, final_action=final_action)
result.operation_advice = advice
if advice_key == "range":
if language == "zh" and "震荡" not in str(result.trend_prediction):
result.trend_prediction = "震荡"
elif language == "en":
result.trend_prediction = "Sideways"
elif language == "ko":
result.trend_prediction = "횡보"
if language == "zh":
no_position = "空仓先不追涨杀跌,等待支撑确认、放量突破或资金回流后再行动。"
has_position = "持仓以关键支撑为风控线,未跌破前以观察和分批控仓为主。"
elif language == "ko":
no_position = "현금 보유 시 추격·투매를 삼가고 지지 확인·대량 돌파·자금 재유입 후 행동하세요."
has_position = "보유 시 핵심 지지선을 리스크 관리선으로 삼고, 이탈 전까지 관찰과 분할 관리 위주로 대응하세요."
else:
no_position = "Do not chase or panic; wait for support confirmation, breakout, or renewed inflow."
has_position = "Use key support as the risk line and manage position size unless support fails."
_apply_hold_watch_dashboard(
result,
language,
advice=advice,
reason=reason,
current_price=current_price,
support=support,
resistance=resistance,
flow_bias=flow_bias,
no_position=no_position,
has_position=has_position,
)
logger.info("[decision_stability] Applied structural hold calibration: %s", reason_key)
def get_stock_name_multi_source(
stock_code: str,
context: Optional[Dict] = None,
data_manager = None
) -> str:
"""
多来源获取股票中文名称
获取策略(按优先级):
1. 从传入的 context 中获取realtime 数据)
2. 从静态映射表 STOCK_NAME_MAP 获取
3. 从 DataFetcherManager 获取(各数据源)
4. 返回默认名称(股票+代码)
Args:
stock_code: 股票代码
context: 分析上下文(可选)
data_manager: DataFetcherManager 实例(可选)
Returns:
股票中文名称
"""
# 1. 从上下文获取(实时行情数据)
if context:
# 优先从 stock_name 字段获取
if context.get('stock_name'):
name = context['stock_name']
if name and not name.startswith('股票'):
return name
# 其次从 realtime 数据获取
if 'realtime' in context and context['realtime'].get('name'):
return context['realtime']['name']
# 2. 从静态映射表获取
if stock_code in STOCK_NAME_MAP:
return STOCK_NAME_MAP[stock_code]
# 3. 从数据源获取
if data_manager is None:
try:
from data_provider.base import DataFetcherManager
data_manager = DataFetcherManager()
except Exception as e:
logger.debug(f"无法初始化 DataFetcherManager: {e}")
if data_manager:
try:
name = data_manager.get_stock_name(stock_code)
if name:
# 更新缓存
STOCK_NAME_MAP[stock_code] = name
return name
except Exception as e:
logger.debug(f"从数据源获取股票名称失败: {e}")
# 4. 返回默认名称
return f'股票{stock_code}'
@dataclass
class AnalysisResult:
"""
AI 分析结果数据类 - 决策仪表盘版
封装 Gemini 返回的分析结果,包含决策仪表盘和详细分析
"""
code: str
name: str
# ========== 核心指标 ==========
sentiment_score: int # 综合评分 0-100 (>70强烈看多, >60看多, 40-60震荡, <40看空)
trend_prediction: str # 趋势预测:强烈看多/看多/震荡/看空/强烈看空
operation_advice: str # 操作建议:买入/加仓/持有/减仓/卖出/观望
decision_type: str = "hold" # 决策类型buy/hold/sell用于统计
confidence_level: str = "" # 置信度:高/中/低
report_language: str = "zh" # 报告输出语言zh/en
action: Optional[str] = None # 建议动作 taxonomybuy/add/hold/reduce/sell/watch/avoid/alert
action_label: Optional[str] = None # 本地化建议动作标签
# ========== 决策仪表盘 (新增) ==========
dashboard: Optional[Dict[str, Any]] = None # 完整的决策仪表盘数据
# ========== 走势分析 ==========
trend_analysis: str = "" # 走势形态分析(支撑位、压力位、趋势线等)
short_term_outlook: str = "" # 短期展望1-3日
medium_term_outlook: str = "" # 中期展望1-2周
# ========== 技术面分析 ==========
technical_analysis: str = "" # 技术指标综合分析
ma_analysis: str = "" # 均线分析(多头/空头排列,金叉/死叉等)
volume_analysis: str = "" # 量能分析(放量/缩量,主力动向等)
pattern_analysis: str = "" # K线形态分析
# ========== 基本面分析 ==========
fundamental_analysis: str = "" # 基本面综合分析
sector_position: str = "" # 板块地位和行业趋势
company_highlights: str = "" # 公司亮点/风险点
# ========== 情绪面/消息面分析 ==========
news_summary: str = "" # 近期重要新闻/公告摘要
market_sentiment: str = "" # 市场情绪分析
hot_topics: str = "" # 相关热点话题
# ========== 综合分析 ==========
analysis_summary: str = "" # 综合分析摘要
key_points: str = "" # 核心看点3-5个要点
risk_warning: str = "" # 风险提示
buy_reason: str = "" # 买入/卖出理由
# ========== 元数据 ==========
market_snapshot: Optional[Dict[str, Any]] = None # 当日行情快照(展示用)
raw_response: Optional[str] = None # 原始响应(调试用)
search_performed: bool = False # 是否执行了联网搜索
# 新闻检索实际命中的条数。None 表示未执行检索(如未配置搜索渠道),
# 0 表示执行了检索但一条也没拿到;报告会针对两种原因使用不同披露文案。
news_result_count: Optional[int] = None
# 旧历史记录未持久化 news_result_count重建时必须与明确的 None 区分,
# 否则会把未知旧数据误报成「未配置搜索渠道」。实时分析默认值始终可信。
news_result_count_known: bool = True
# 本次分析实际收到的消息面证据news_context是否非空。
# news_result_count 只是「实时搜索命中了几条」,而披露断言的是「结论有没有用到
# 新闻面证据」——两者是不同命题news_context 还可能来自社交情绪或本地已落库的
# 资讯池,这些同样进入模型输入却不产生搜索命中。只看计数会把这类分析误报成
# 「未纳入新闻面证据」。
news_evidence_present: bool = False
data_sources: str = "" # 数据来源说明
success: bool = True
error_message: Optional[str] = None
# ========== 价格数据(分析时快照)==========
current_price: Optional[float] = None # 分析时的股价
change_pct: Optional[float] = None # 分析时的涨跌幅(%)
# ========== 模型标记Issue #528==========
model_used: Optional[str] = None # 分析使用的 LLM 模型(完整名,如 gemini/gemini-2.0-flash
# ========== 历史对比Report Engine P0==========
query_id: Optional[str] = None # 本次分析 query_id用于历史对比时排除本次记录
# ========== 基本面上下文(仅运行时,用于通知拼装;不持久化到 to_dict==========
fundamental_context: Optional[Dict[str, Any]] = None
market_structure_context: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
'code': self.code,
'name': self.name,
'sentiment_score': self.sentiment_score,
'trend_prediction': self.trend_prediction,
'operation_advice': self.operation_advice,
'decision_type': self.decision_type,
'confidence_level': self.confidence_level,
'report_language': self.report_language,
'action': self.action,
'action_label': self.action_label,
'dashboard': self.dashboard, # 决策仪表盘数据
'trend_analysis': self.trend_analysis,
'short_term_outlook': self.short_term_outlook,
'medium_term_outlook': self.medium_term_outlook,
'technical_analysis': self.technical_analysis,
'ma_analysis': self.ma_analysis,
'volume_analysis': self.volume_analysis,
'pattern_analysis': self.pattern_analysis,
'fundamental_analysis': self.fundamental_analysis,
'sector_position': self.sector_position,
'company_highlights': self.company_highlights,
'news_summary': self.news_summary,
'market_sentiment': self.market_sentiment,
'hot_topics': self.hot_topics,
'analysis_summary': self.analysis_summary,
'key_points': self.key_points,
'risk_warning': self.risk_warning,
'buy_reason': self.buy_reason,
'market_snapshot': self.market_snapshot,
'search_performed': self.search_performed,
'news_result_count': self.news_result_count,
'news_result_count_known': self.news_result_count_known,
'news_evidence_present': self.news_evidence_present,
'success': self.success,
'error_message': self.error_message,
'current_price': self.current_price,
'change_pct': self.change_pct,
'model_used': self.model_used,
'market_structure_context': self.market_structure_context,
}
def get_core_conclusion(self) -> str:
"""获取核心结论(一句话)"""
if self.dashboard and 'core_conclusion' in self.dashboard:
return self.dashboard['core_conclusion'].get('one_sentence', self.analysis_summary)
return self.analysis_summary
def get_position_advice(self, has_position: bool = False) -> str:
"""获取持仓建议"""
if self.dashboard and 'core_conclusion' in self.dashboard:
pos_advice = self.dashboard['core_conclusion'].get('position_advice', {})
if has_position:
return pos_advice.get('has_position', self.operation_advice)
return pos_advice.get('no_position', self.operation_advice)
return self.operation_advice
def get_sniper_points(self) -> Dict[str, str]:
"""获取狙击点位"""
if self.dashboard and 'battle_plan' in self.dashboard:
return self.dashboard['battle_plan'].get('sniper_points', {})
return {}
def get_checklist(self) -> List[str]:
"""获取检查清单"""
if self.dashboard and 'battle_plan' in self.dashboard:
return self.dashboard['battle_plan'].get('action_checklist', [])
return []
def get_risk_alerts(self) -> List[str]:
"""获取风险警报"""
if self.dashboard and 'intelligence' in self.dashboard:
return self.dashboard['intelligence'].get('risk_alerts', [])
return []
def get_emoji(self) -> str:
"""根据操作建议返回对应 emoji"""
_, emoji, _ = get_signal_level(
self.operation_advice,
self.sentiment_score,
self.report_language,
)
return emoji
def get_confidence_stars(self) -> str:
"""返回置信度星级"""
star_map = {
"": "⭐⭐⭐",
"high": "⭐⭐⭐",
"": "⭐⭐",
"medium": "⭐⭐",
"": "",
"low": "",
}
return star_map.get(str(self.confidence_level or "").strip().lower(), "⭐⭐")
def populate_decision_action_fields(
result: AnalysisResult,
*,
explicit_action: Any = None,
report_type: Any = None,
use_existing_action: bool = True,
align_with_score: bool = True,
) -> AnalysisResult:
"""Populate optional decision action fields without changing legacy advice."""
action_source = explicit_action
if action_source is None and use_existing_action:
action_source = getattr(result, "action", None)
fields = build_action_fields(
operation_advice=getattr(result, "operation_advice", None),
explicit_action=action_source,
report_type=report_type,
report_language=getattr(result, "report_language", "zh"),
sentiment_score=getattr(result, "sentiment_score", None),
guardrail_reason=getattr(result, "guardrail_reason", None),
align_with_score=align_with_score,
)
result.action = fields["action"]
result.action_label = fields["action_label"]
return result
class GeminiAnalyzer:
"""
Gemini AI 分析器
职责:
1. 调用 Google Gemini API 进行股票分析
2. 结合预先搜索的新闻和技术面数据生成分析报告
3. 解析 AI 返回的 JSON 格式结果
使用方式:
analyzer = GeminiAnalyzer()
result = analyzer.analyze(context, news_context)
"""
# ========================================
# 系统提示词 - 决策仪表盘 v2.0
# ========================================
# 输出格式升级:从简单信号升级为决策仪表盘
# 核心模块:核心结论 + 数据透视 + 舆情情报 + 作战计划
# ========================================
LEGACY_DEFAULT_SYSTEM_PROMPT = """你是一位专注于趋势交易的{market_placeholder}投资分析师,负责生成专业的【决策仪表盘】分析报告。
{guidelines_placeholder}
""" + CORE_TRADING_SKILL_POLICY_ZH + """
""" + CANONICAL_DECISION_SCALE_PROMPT_ZH + """
## 输出格式:决策仪表盘 JSON
请严格按照以下 JSON 格式输出,这是一个完整的【决策仪表盘】:
```json
{
"stock_name": "股票中文名称",
"sentiment_score": 0-100整数,
"trend_prediction": "强烈看多/看多/震荡/看空/强烈看空",
"operation_advice": "买入/加仓/持有/减仓/卖出/观望",
"decision_type": "buy/hold/sell",
"action": "buy/add/hold/reduce/sell/watch/avoid/alert",
"guardrail_reason": "当分数区间与最终 action 不一致时填写降级/升级原因,否则留空",
"confidence_level": "高/中/低",
"dashboard": {
"core_conclusion": {
"one_sentence": "一句话核心结论30字以内直接告诉用户做什么",
"signal_type": "🟢买入信号/🟡持有观望/🔴卖出信号/⚠️风险警告",
"time_sensitivity": "立即行动/今日内/本周内/不急",
"position_advice": {
"no_position": "空仓者建议:具体操作指引",
"has_position": "持仓者建议:具体操作指引"
}
},
"data_perspective": {
"trend_status": {
"ma_alignment": "均线排列状态描述",
"is_bullish": true/false,
"trend_score": 0-100
},
"price_position": {
"current_price": 当前价格数值,
"ma5": MA5数值,
"ma10": MA10数值,
"ma20": MA20数值,
"bias_ma5": 乖离率百分比数值,
"bias_status": "安全/警戒/危险",
"support_level": 支撑位价格,
"resistance_level": 压力位价格
},
"volume_analysis": {
"volume_ratio": 量比数值,
"volume_status": "放量/缩量/平量",
"turnover_rate": 换手率百分比,
"volume_meaning": "量能含义解读(如:缩量回调表示抛压减轻)"
},
"chip_structure": {
"profit_ratio": 获利比例,
"avg_cost": 平均成本,
"concentration": 筹码集中度,
"chip_health": "健康/一般/警惕"
}
},
"intelligence": {
"latest_news": "【最新消息】近期重要新闻摘要",
"risk_alerts": ["风险点1具体描述", "风险点2具体描述"],
"positive_catalysts": ["利好1具体描述", "利好2具体描述"],
"earnings_outlook": "业绩预期分析(基于年报预告、业绩快报等)",
"sentiment_summary": "舆情情绪一句话总结"
},
"battle_plan": {
"sniper_points": {
"ideal_buy": "理想买入点XX元在MA5附近",
"secondary_buy": "次优买入点XX元在MA10附近",
"stop_loss": "止损位XX元跌破MA20或X%",
"take_profit": "目标位XX元前高/整数关口)"
},
"position_strategy": {
"suggested_position": "建议仓位X成",
"entry_plan": "分批建仓策略描述",
"risk_control": "风控策略描述"
},
"action_checklist": [
"✅/⚠️/❌ 检查项1多头排列",
"✅/⚠️/❌ 检查项2乖离率合理强势趋势可放宽",
"✅/⚠️/❌ 检查项3量能配合",
"✅/⚠️/❌ 检查项4无重大利空",
"✅/⚠️/❌ 检查项5筹码健康",
"✅/⚠️/❌ 检查项6PE估值合理"
]
},
"phase_decision": {
"phase_context": {"phase": "premarket/intraday/lunch_break/closing_auction/postmarket/non_trading/unknown"},
"action_window": "盘前计划/盘中跟踪/午间确认/收盘前风控/盘后复盘/非交易日观察",
"immediate_action": "立即行动/等待确认/观察/止损止盈预警/禁止追高/无盘中动作",
"watch_conditions": ["观察条件1", "观察条件2"],
"next_check_time": "下一次检查点或市场本地时间",
"confidence_reason": "置信度理由,说明阶段和数据质量限制",
"data_limitations": ["阶段或数据质量限制1", "阶段或数据质量限制2"]
},
"signal_attribution": {
"technical_indicators": 技术指标贡献度(0-100),
"news_sentiment": 新闻舆情贡献度(0-100),
"fundamentals": 基本面贡献度(0-100),
"market_conditions": 市场环境贡献度(0-100),
"strongest_bullish_signal": "最强看多信号名称",
"strongest_bearish_signal": "最强看空信号名称"
}
},
"analysis_summary": "100字综合分析摘要",
"key_points": "3-5个核心看点逗号分隔",
"risk_warning": "风险提示",
"buy_reason": "操作理由,引用交易理念",
"trend_analysis": "走势形态分析",
"short_term_outlook": "短期1-3日展望",
"medium_term_outlook": "中期1-2周展望",
"technical_analysis": "技术面综合分析",
"ma_analysis": "均线系统分析",
"volume_analysis": "量能分析",
"pattern_analysis": "K线形态分析",
"fundamental_analysis": "基本面分析",
"sector_position": "板块行业分析",
"company_highlights": "公司亮点/风险",
"news_summary": "新闻摘要",
"market_sentiment": "市场情绪",
"hot_topics": "相关热点",
"search_performed": true/false,
"data_sources": "数据来源说明"
}
```
## 评分标准
### 强烈买入80-100分
- ✅ 多头排列MA5 > MA10 > MA20
- ✅ 低乖离率:<2%,最佳买点
- ✅ 缩量回调或放量突破
- ✅ 筹码集中健康
- ✅ 消息面有利好催化
### 买入60-79分
- ✅ 多头排列或弱势多头
- ✅ 乖离率 <5%
- ✅ 量能正常
- ⚪ 允许一项次要条件不满足
### 观望40-59分
- ⚠️ 乖离率 >5%(追高风险)
- ⚠️ 均线缠绕趋势不明
- ⚠️ 有风险事件
### 减仓20-39分
- ⚠️ 趋势走弱或跌破关键均线
- ⚠️ 资金/量能转弱,风险明显高于收益
- ⚠️ 以降低仓位和保护收益为主
### 卖出0-19分
- ❌ 空头排列或趋势显著恶化
- ❌ 跌破关键支撑/止损位
- ❌ 放量下跌或重大利空
## 决策仪表盘核心原则
1. **核心结论先行**:一句话说清该买该卖
2. **分持仓建议**:空仓者和持仓者给不同建议
3. **精确狙击点**:必须给出具体价格,不说模糊的话
4. **检查清单可视化**:用 ✅⚠️❌ 明确显示每项检查结果
5. **风险优先级**:舆情中的风险点要醒目标出
## 可操作性与稳定性约束
- 不得仅因为单日涨跌或评分跨线就在“买入/卖出”之间剧烈切换。
- 操作建议必须同时参考价格位置(支撑/压力位)、量能/筹码、主力资金流向和风险事件。
- 股价位于支撑与压力之间、资金流不明确时,优先输出“持有/震荡/观望/洗盘观察”等可执行的中性建议;`decision_type` 仍保持 `hold`。
- 只有在接近支撑确认或有效突破压力,且资金流/量价配合时,才能给出买入;接近压力且资金流出时不得追买。
- 只有在跌破关键支撑、主力资金持续流出或风险显著放大时,才能给出卖出/减仓。
- 必须输出 `dashboard.phase_decision` 七字段;盘中/午休/临近收盘要给出当前动作、观察条件和下一次检查点。
- 建议输出可选展示字段 `dashboard.signal_attribution` 六字段;解释推荐理由的构成,包括技术指标、新闻舆情、基本面、市场环境的贡献度,以及最强看多/看空信号。
- 盘前、非交易日或未知阶段不得伪造今日盘中走势quote/daily_bars/technical 存在 stale、fallback、missing、fetch_failed、partial 或 estimated 时,`confidence_level` 不得为高。"""
SYSTEM_PROMPT = """你是一位{market_placeholder}投资分析师,负责生成专业的【决策仪表盘】分析报告。
{guidelines_placeholder}
{default_skill_policy_section}
{skills_section}
""" + CANONICAL_DECISION_SCALE_PROMPT_ZH + """
## 输出格式:决策仪表盘 JSON
请严格按照以下 JSON 格式输出,这是一个完整的【决策仪表盘】:
```json
{
"stock_name": "股票中文名称",
"sentiment_score": 0-100整数,
"trend_prediction": "强烈看多/看多/震荡/看空/强烈看空",
"operation_advice": "买入/加仓/持有/减仓/卖出/观望",
"decision_type": "buy/hold/sell",
"action": "buy/add/hold/reduce/sell/watch/avoid/alert",
"guardrail_reason": "当分数区间与最终 action 不一致时填写降级/升级原因,否则留空",
"confidence_level": "高/中/低",
"dashboard": {
"core_conclusion": {
"one_sentence": "一句话核心结论30字以内直接告诉用户做什么",
"signal_type": "🟢买入信号/🟡持有观望/🔴卖出信号/⚠️风险警告",
"time_sensitivity": "立即行动/今日内/本周内/不急",
"position_advice": {
"no_position": "空仓者建议:具体操作指引",
"has_position": "持仓者建议:具体操作指引"
}
},
"data_perspective": {
"trend_status": {
"ma_alignment": "均线排列状态描述",
"is_bullish": true/false,
"trend_score": 0-100
},
"price_position": {
"current_price": 当前价格数值,
"ma5": MA5数值,
"ma10": MA10数值,
"ma20": MA20数值,
"bias_ma5": 乖离率百分比数值,
"bias_status": "安全/警戒/危险",
"support_level": 支撑位价格,
"resistance_level": 压力位价格
},
"volume_analysis": {
"volume_ratio": 量比数值,
"volume_status": "放量/缩量/平量",
"turnover_rate": 换手率百分比,
"volume_meaning": "量能含义解读(如:缩量回调表示抛压减轻)"
},
"chip_structure": {
"profit_ratio": 获利比例,
"avg_cost": 平均成本,
"concentration": 筹码集中度,
"chip_health": "健康/一般/警惕"
}
},
"intelligence": {
"latest_news": "【最新消息】近期重要新闻摘要",
"risk_alerts": ["风险点1具体描述", "风险点2具体描述"],
"positive_catalysts": ["利好1具体描述", "利好2具体描述"],
"earnings_outlook": "业绩预期分析(基于年报预告、业绩快报等)",
"sentiment_summary": "舆情情绪一句话总结"
},
"battle_plan": {
"sniper_points": {
"ideal_buy": "理想入场位XX元满足主要技能触发条件",
"secondary_buy": "次优入场位XX元更保守或确认后执行",
"stop_loss": "止损位XX元失效条件或X%风险)",
"take_profit": "目标位XX元按阻力位/风险回报比制定)"
},
"position_strategy": {
"suggested_position": "建议仓位X成",
"entry_plan": "分批建仓策略描述",
"risk_control": "风控策略描述"
},
"action_checklist": [
"✅/⚠️/❌ 检查项1当前结构是否满足激活技能条件",
"✅/⚠️/❌ 检查项2入场位置与风险回报是否合理",
"✅/⚠️/❌ 检查项3量价/波动/筹码是否支持判断",
"✅/⚠️/❌ 检查项4无重大利空",
"✅/⚠️/❌ 检查项5仓位与止损计划明确",
"✅/⚠️/❌ 检查项6估值/业绩/催化与结论匹配"
]
},
"phase_decision": {
"phase_context": {"phase": "premarket/intraday/lunch_break/closing_auction/postmarket/non_trading/unknown"},
"action_window": "盘前计划/盘中跟踪/午间确认/收盘前风控/盘后复盘/非交易日观察",
"immediate_action": "立即行动/等待确认/观察/止损止盈预警/禁止追高/无盘中动作",
"watch_conditions": ["观察条件1", "观察条件2"],
"next_check_time": "下一次检查点或市场本地时间",
"confidence_reason": "置信度理由,说明阶段和数据质量限制",
"data_limitations": ["阶段或数据质量限制1", "阶段或数据质量限制2"]
},
"signal_attribution": {
"technical_indicators": 技术指标贡献度(0-100),
"news_sentiment": 新闻舆情贡献度(0-100),
"fundamentals": 基本面贡献度(0-100),
"market_conditions": 市场环境贡献度(0-100),
"strongest_bullish_signal": "最强看多信号名称",
"strongest_bearish_signal": "最强看空信号名称"
}
},
"analysis_summary": "100字综合分析摘要",
"key_points": "3-5个核心看点逗号分隔",
"risk_warning": "风险提示",
"buy_reason": "操作理由,引用激活技能或风险框架",
"trend_analysis": "走势形态分析",
"short_term_outlook": "短期1-3日展望",
"medium_term_outlook": "中期1-2周展望",
"technical_analysis": "技术面综合分析",
"ma_analysis": "均线系统分析",
"volume_analysis": "量能分析",
"pattern_analysis": "K线形态分析",
"fundamental_analysis": "基本面分析",
"sector_position": "板块行业分析",
"company_highlights": "公司亮点/风险",
"news_summary": "新闻摘要",
"market_sentiment": "市场情绪",
"hot_topics": "相关热点",
"search_performed": true/false,
"data_sources": "数据来源说明"
}
```
## 评分标准
### 强烈买入80-100分
- ✅ 多个激活技能同时支持积极结论
- ✅ 上行空间、触发条件与风险回报清晰
- ✅ 关键风险已排查,仓位与止损计划明确
- ✅ 重要数据和情报结论彼此一致
### 买入60-79分
- ✅ 主信号偏积极,但仍有少量待确认项
- ✅ 允许存在可控风险或次优入场点
- ✅ 需要在报告中明确补充观察条件
### 观望40-59分
- ⚠️ 信号分歧较大,或缺乏足够确认
- ⚠️ 风险与机会大致均衡
- ⚠️ 更适合等待触发条件或回避不确定性
### 减仓20-39分
- ⚠️ 主要结论转弱,风险明显高于收益
- ⚠️ 触发了部分失效条件,现有仓位需要降低暴露
- ⚠️ 更适合保护收益而不是进攻
### 卖出0-19分
- ❌ 触发了止损/失效条件或重大利空
- ❌ 趋势或风险显著恶化
- ❌ 现有仓位应优先退出
## 决策仪表盘核心原则
1. **核心结论先行**:一句话说清该买该卖
2. **分持仓建议**:空仓者和持仓者给不同建议
3. **精确狙击点**:必须给出具体价格,不说模糊的话
4. **检查清单可视化**:用 ✅⚠️❌ 明确显示每项检查结果
5. **风险优先级**:舆情中的风险点要醒目标出
## 可操作性与稳定性约束
- 不得仅因为单日涨跌或评分跨线就在“买入/卖出”之间剧烈切换。
- 操作建议必须同时参考价格位置(支撑/压力位)、量能/筹码、主力资金流向和风险事件。
- 股价位于支撑与压力之间、资金流不明确时,优先输出“持有/震荡/观望/洗盘观察”等可执行的中性建议;`decision_type` 仍保持 `hold`。
- 只有在接近支撑确认或有效突破压力,且资金流/量价配合时,才能给出买入;接近压力且资金流出时不得追买。
- 只有在跌破关键支撑、主力资金持续流出或风险显著放大时,才能给出卖出/减仓。
- 必须输出 `dashboard.phase_decision` 七字段;盘中/午休/临近收盘要给出当前动作、观察条件和下一次检查点。
- 建议输出可选展示字段 `dashboard.signal_attribution` 六字段;解释推荐理由的构成,包括技术指标、新闻舆情、基本面、市场环境的贡献度,以及最强看多/看空信号。
- 盘前、非交易日或未知阶段不得伪造今日盘中走势quote/daily_bars/technical 存在 stale、fallback、missing、fetch_failed、partial 或 estimated 时,`confidence_level` 不得为高。"""
TEXT_SYSTEM_PROMPT = """你是一位专业的股票分析助手。
- 回答必须基于用户提供的数据与上下文
- 若信息不足,要明确指出不确定性
- 不要编造价格、财报或新闻事实
"""
def __init__(
self,
api_key: Optional[str] = None,
*,
config: Optional[Config] = None,
skills: Optional[List[str]] = None,
skill_instructions: Optional[str] = None,
default_skill_policy: Optional[str] = None,
use_legacy_default_prompt: Optional[bool] = None,
):
"""Initialize LLM Analyzer via LiteLLM.
Args:
api_key: Ignored (kept for backward compatibility). Keys are loaded from config.
"""
self._config_override = config
self._requested_skills = list(skills) if skills is not None else None
self._skill_instructions_override = skill_instructions
self._default_skill_policy_override = default_skill_policy
self._use_legacy_default_prompt_override = use_legacy_default_prompt
self._resolved_prompt_state: Optional[Dict[str, Any]] = None
self._router = None
self._legacy_router_model_list: List[Dict[str, Any]] = []
self._litellm_available = False
self._init_litellm()
if not self._litellm_available:
try:
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
except GenerationError:
backend_id = ""
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
logger.info(
"Analyzer generation backend: %s configured; LiteLLM API keys are not "
"required for stock analysis generation",
backend_id,
)
else:
logger.warning("No LLM configured (LITELLM_MODEL / API keys), AI analysis will be unavailable")
def _get_runtime_config(self) -> Config:
"""Return the runtime config, honoring injected overrides for tests/pipeline."""
return getattr(self, "_config_override", None) or get_config()
def _get_skill_prompt_sections(self) -> tuple[str, str, bool]:
"""Resolve skill instructions + default baseline + prompt mode."""
skill_instructions = getattr(self, "_skill_instructions_override", None)
default_skill_policy = getattr(self, "_default_skill_policy_override", None)
use_legacy_default_prompt = getattr(self, "_use_legacy_default_prompt_override", None)
if skill_instructions is not None and default_skill_policy is not None:
return (
skill_instructions,
default_skill_policy,
bool(use_legacy_default_prompt) if use_legacy_default_prompt is not None else False,
)
resolved_state = getattr(self, "_resolved_prompt_state", None)
if resolved_state is None:
from src.agent.factory import resolve_skill_prompt_state
prompt_state = resolve_skill_prompt_state(
self._get_runtime_config(),
skills=getattr(self, "_requested_skills", None),
)
resolved_state = {
"skill_instructions": prompt_state.skill_instructions,
"default_skill_policy": prompt_state.default_skill_policy,
"use_legacy_default_prompt": bool(getattr(prompt_state, "use_legacy_default_prompt", False)),
}
self._resolved_prompt_state = resolved_state
return (
skill_instructions if skill_instructions is not None else resolved_state.get("skill_instructions", ""),
default_skill_policy if default_skill_policy is not None else resolved_state.get("default_skill_policy", ""),
(
use_legacy_default_prompt
if use_legacy_default_prompt is not None
else bool(resolved_state.get("use_legacy_default_prompt", False))
),
)
def _get_analysis_system_prompt(self, report_language: str, stock_code: str = "") -> str:
"""Build the analyzer system prompt with output-language guidance."""
lang = normalize_report_language(report_language)
market_role = get_market_role(stock_code, lang)
market_guidelines = get_market_guidelines(stock_code, lang)
skill_instructions, default_skill_policy, use_legacy_default_prompt = self._get_skill_prompt_sections()
if use_legacy_default_prompt:
base_prompt = self.LEGACY_DEFAULT_SYSTEM_PROMPT.replace(
"{market_placeholder}", market_role
).replace(
"{guidelines_placeholder}", market_guidelines
)
else:
skills_section = ""
if skill_instructions:
skills_section = f"## 激活的交易技能\n\n{skill_instructions}\n"
default_skill_policy_section = ""
if default_skill_policy:
default_skill_policy_section = f"{default_skill_policy}\n"
base_prompt = (
self.SYSTEM_PROMPT.replace("{market_placeholder}", market_role)
.replace("{guidelines_placeholder}", market_guidelines)
.replace("{default_skill_policy_section}", default_skill_policy_section)
.replace("{skills_section}", skills_section)
)
if lang == "en":
return base_prompt + """
## Output Language (highest priority)
- Keep all JSON keys unchanged.
- `decision_type` must remain `buy|hold|sell`.
- All human-readable JSON values must be written in English.
- Use the common English company name when you are confident; otherwise keep the original listed company name instead of inventing one.
- This includes `stock_name`, `trend_prediction`, `operation_advice`, `confidence_level`, nested dashboard text, checklist items, and all narrative summaries.
"""
if lang == "ko":
return base_prompt + """
## Output Language (highest priority)
- Keep all JSON keys unchanged.
- `decision_type` must remain `buy|hold|sell`.
- All human-readable JSON values must be written in Korean (한국어).
- Use the common Korean or original listed company name when confident; do not invent one.
- This includes `stock_name`, `trend_prediction`, `operation_advice`, `confidence_level`, nested dashboard text, checklist items, and all narrative summaries.
"""
return base_prompt + """
## 输出语言(最高优先级)
- 所有 JSON 键名保持不变。
- `decision_type` 必须保持为 `buy|hold|sell`。
- 所有面向用户的人类可读文本值必须使用中文。
"""
def _has_channel_config(self, config: Config) -> bool:
"""Check if multi-channel config (channels / YAML / legacy model_list) is active."""
return bool(config.llm_model_list) and not all(
e.get('model_name', '').startswith('__legacy_') for e in config.llm_model_list
)
@staticmethod
def _legacy_router_provider_alias(model: str) -> str:
provider = model.split("/", 1)[0] if "/" in model else "openai"
return f"__legacy_{provider}__"
@staticmethod
def _build_legacy_router_model_list_from_config(
model: str,
model_list: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Build legacy-router candidates from configured legacy llm_model_list entries."""
if not model:
return []
target_model = model
target_legacy_alias = GeminiAnalyzer._legacy_router_provider_alias(model)
legacy_entries: List[Dict[str, Any]] = []
for entry in model_list or []:
if not isinstance(entry, dict):
continue
model_name = str(entry.get("model_name") or "").strip()
if model_name != target_legacy_alias:
continue
params = entry.get("litellm_params")
if not isinstance(params, dict):
continue
api_key = str(params.get("api_key") or "").strip()
if not api_key or len(api_key) < 8:
continue
deployed_params = dict(params)
deployed_params["model"] = target_model
deployed_params["api_key"] = api_key
legacy_entries.append({
"model_name": target_model,
"litellm_params": deployed_params,
})
return legacy_entries
def _init_litellm(self) -> None:
"""Initialize litellm Router from channels / YAML / legacy keys."""
config = self._get_runtime_config()
if self._get_hermes_config_error(config) is not None:
logger.error("Analyzer LLM: Hermes channel configuration blocks legacy fallback")
return
litellm_model = config.litellm_model
if not litellm_model:
backend_id = ""
try:
backend_id = resolve_generation_backend_id(config)
except GenerationError:
pass
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
logger.info(
"Analyzer LiteLLM: LITELLM_MODEL not configured; using %s generation backend",
backend_id,
)
else:
logger.warning("Analyzer LLM: LITELLM_MODEL not configured")
return
self._litellm_available = True
# --- Channel / YAML path: build Router from pre-built model_list ---
if self._has_channel_config(config):
model_list = config.llm_model_list
if self._get_mixed_hermes_route_error(config, litellm_model) is not None:
self._litellm_available = False
logger.error("Analyzer LLM: mixed Hermes/non-Hermes route requires deployment-level no-proxy support")
return
router_model_list = model_list
if route_has_hermes(model_list, litellm_model):
# Hermes-only routes are dispatched directly with a request-scoped
# no-proxy OpenAI client. Keeping them out of Router prevents the
# default proxy-aware transport from seeing the Hermes bearer key.
router_model_list = filter_non_hermes_deployments(model_list)
if not router_model_list:
self._litellm_available = True
logger.info("Analyzer LLM: Hermes-only route will use direct no-proxy completion")
return
try:
self._router = Router(
model_list=router_model_list,
routing_strategy="simple-shuffle",
num_retries=2,
)
except TypeError:
logger.debug("Analyzer LLM: Router constructor signature not compatible; fallback to direct mode")
self._router = None
else:
unique_models = list(dict.fromkeys(
e['litellm_params']['model'] for e in model_list
))
logger.info(
f"Analyzer LLM: Router initialized from channels/YAML — "
f"{len(router_model_list)} deployment(s), models: {unique_models}"
)
return
# --- Legacy path: build Router for multi-key, or use single key ---
keys = get_api_keys_for_model(litellm_model, config)
legacy_model_list = self._build_legacy_router_model_list_from_config(
litellm_model,
config.llm_model_list,
)
if len(legacy_model_list) <= 1 and keys:
extra_params = extra_litellm_params(litellm_model, config)
configured_model_list = [
{
"model_name": litellm_model,
"litellm_params": {
"model": litellm_model,
"api_key": k,
**extra_params,
},
}
for k in keys
]
if not legacy_model_list:
legacy_model_list = configured_model_list
elif len(legacy_model_list) < len(configured_model_list):
legacy_model_list = configured_model_list
if len(legacy_model_list) > 1:
self._legacy_router_model_list = legacy_model_list
try:
self._router = Router(
model_list=legacy_model_list,
routing_strategy="simple-shuffle",
num_retries=2,
)
except TypeError:
logger.debug("Analyzer LLM: Legacy Router constructor signature not compatible; using legacy model_list fallback")
self._router = None
else:
logger.info(
f"Analyzer LLM: Legacy Router initialized with {len(legacy_model_list)} keys "
f"for {litellm_model}"
)
return
if keys:
logger.info(f"Analyzer LLM: litellm initialized (model={litellm_model})")
else:
logger.info(
f"Analyzer LLM: litellm initialized (model={litellm_model}, "
f"API key from environment)"
)
def is_available(self) -> bool:
"""Check whether the configured generation backend is available."""
backend_error = self.get_generation_backend_config_error()
if backend_error is not None:
return self._can_use_generation_fallback(backend_error)
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
return True
return self._litellm_runtime_available()
def _litellm_runtime_available(self) -> bool:
return self._router is not None or self._litellm_available
def _can_use_generation_fallback(self, backend_error: GenerationError) -> bool:
if not backend_error.fallbackable:
return False
try:
_backend_id, fallback_backend_id = self._resolve_generation_backend_config()
except GenerationError:
return False
return (
fallback_backend_id == LITELLM_BACKEND_ID
and self._litellm_runtime_available()
)
def _resolve_generation_backend_config(self) -> Tuple[str, Optional[str]]:
"""Resolve and validate generation backend ids."""
config = self._get_runtime_config()
backend_id = resolve_generation_backend_id(config)
fallback_backend_id = resolve_generation_fallback_backend_id(config)
return backend_id, fallback_backend_id
def get_generation_backend_config_error(self) -> Optional[GenerationError]:
"""Return a structured backend config error, if the backend cannot run."""
try:
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
config = self._get_runtime_config()
hermes_error = self._get_hermes_config_error(config)
if hermes_error is not None:
return hermes_error
for model in [getattr(config, "litellm_model", "")] + list(getattr(config, "litellm_fallback_models", []) or []):
mixed_error = self._get_mixed_hermes_route_error(config, model)
if mixed_error is not None:
return mixed_error
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
backend = self._get_generation_backend(backend_id)
get_config_error = getattr(backend, "get_config_error", None)
if callable(get_config_error):
return get_config_error()
except GenerationError as exc:
return exc
return None
def _get_hermes_config_error(self, config: Config) -> Optional[GenerationError]:
issues = list(getattr(config, "llm_channel_config_issues", []) or [])
if not getattr(config, "llm_blocks_legacy_fallback", False) or not issues:
return None
blocked_routes = set(getattr(config, "llm_blocked_hermes_routes", []) or [])
selected_models = [
("LITELLM_MODEL", getattr(config, "litellm_model", "") or ""),
*[
("LITELLM_FALLBACK_MODELS", fallback_model)
for fallback_model in list(getattr(config, "litellm_fallback_models", []) or [])
],
]
selected_blocked_route = ""
selected_field = ""
for field_name, model in selected_models:
raw_model = str(model or "").strip()
if not raw_model:
continue
candidates = hermes_blocked_route_candidates(raw_model)
candidates.add(raw_model)
try:
candidates.add(canonicalize_hermes_model_ref(raw_model).route_model)
except (TypeError, ValueError) as exc:
logger.debug("Failed to canonicalize selected Hermes route candidate %r: %s", raw_model, exc)
matched = candidates & blocked_routes
if matched:
selected_blocked_route = sorted(matched)[0]
selected_field = field_name
break
if blocked_routes and not selected_blocked_route and getattr(config, "llm_model_list", None):
return None
first = issues[0]
code = (
"explicit_hermes_route_invalid"
if selected_blocked_route
else first.get("code", "invalid_hermes_channel")
)
return GenerationError(
error_code=GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
backend=LITELLM_BACKEND_ID,
provider=HERMES_CHANNEL_NAME,
details={
"field": selected_field or first.get("field", "LLM_HERMES_API_KEY"),
"code": code,
"reason": code,
"message": first.get("message", "Hermes channel configuration is invalid"),
"issues": issues,
"route_name": selected_blocked_route or None,
},
)
def _get_mixed_hermes_route_error(self, config: Config, model: str) -> Optional[GenerationError]:
if not model:
return None
origins = route_deployment_origins(getattr(config, "llm_model_list", []) or [], model)
if not origins.is_mixed:
return None
return GenerationError(
error_code=GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
backend=LITELLM_BACKEND_ID,
provider=HERMES_CHANNEL_NAME,
details={
"field": "LLM_CHANNELS",
"code": "mixed_hermes_route_unsupported",
"reason": "router_deployment_no_proxy_unavailable",
"route_name": model,
},
)
def _hermes_redaction_values_for_model(self, config: Config, model: str = "") -> set[str]:
redactions: set[str] = set()
deployments = list(getattr(config, "llm_model_list", []) or [])
selected_deployments = deployments
if model:
origins = route_deployment_origins(deployments, model)
selected_deployments = list(origins.hermes_deployments or [])
if not selected_deployments and not origins.has_hermes:
return redactions
for deployment in selected_deployments:
if not isinstance(deployment, dict):
continue
if not route_has_hermes([deployment], str(deployment.get("model_name") or "")):
continue
params = deployment.get("litellm_params") or {}
if isinstance(params, dict):
redactions.update(build_hermes_redaction_values(params.get("api_key")))
return redactions
def _sanitize_hermes_exception_text(
self,
exc: Any,
*,
config: Optional[Config] = None,
model: str = "",
) -> str:
runtime_config = config or self._get_runtime_config()
redactions = self._hermes_redaction_values_for_model(runtime_config, model)
if not redactions:
return str(exc)
return sanitize_hermes_error_text(exc, redaction_values=redactions)
def _litellm_redaction_values_for_model(self, config: Config, model: str = "") -> set[str]:
redactions = self._hermes_redaction_values_for_model(config, model)
try:
redactions.update(build_hermes_redaction_values(*get_api_keys_for_model(model, config)))
except Exception:
pass
origins = route_deployment_origins(getattr(config, "llm_model_list", []) or [], model)
for deployment in (*origins.hermes_deployments, *origins.non_hermes_deployments):
params = deployment.get("litellm_params") if isinstance(deployment, dict) else None
if isinstance(params, dict):
redactions.update(build_hermes_redaction_values(params.get("api_key")))
return redactions
def _sanitize_litellm_exception_text(
self,
exc: Any,
*,
config: Optional[Config] = None,
model: str = "",
) -> str:
runtime_config = config or self._get_runtime_config()
redactions = self._litellm_redaction_values_for_model(runtime_config, model)
sanitized = sanitize_hermes_error_text(exc, redaction_values=redactions)
return redact_diagnostic_text(sanitized, limit=500)
def _dispatch_litellm_completion(
self,
model: str,
call_kwargs: Dict[str, Any],
*,
config: Config,
use_channel_router: bool,
router_model_names: set[str],
) -> Any:
"""Dispatch a LiteLLM completion through router or direct fallback."""
origins = route_deployment_origins(config.llm_model_list, model)
if origins.is_mixed:
raise RuntimeError("Hermes/non-Hermes mixed generation route is not supported without deployment-level no-proxy client support")
if origins.is_hermes_only:
deployment = origins.hermes_deployments[0]
params = dict(deployment.get("litellm_params") or {})
api_key = str(params.get("api_key") or "").strip()
base_url = str(params.get("api_base") or "").strip()
if is_masked_secret_placeholder(api_key):
raise RuntimeError("Hermes API key is a masked placeholder and cannot be used for generation")
timeout = float(call_kwargs.get("timeout") or 30.0)
hermes_kwargs = dict(call_kwargs)
hermes_kwargs["model"] = str(params.get("model") or model)
hermes_kwargs["stream"] = False
hermes_kwargs.pop("api_key", None)
hermes_kwargs.pop("api_base", None)
with open_hermes_no_proxy_client(api_key=api_key, base_url=base_url, timeout=timeout) as client:
hermes_kwargs["client"] = client
return litellm.completion(**hermes_kwargs)
wire_models = resolve_fallback_litellm_wire_models(model, config.llm_model_list)
register_fallback_model_pricing(wire_models)
effective_kwargs = dict(call_kwargs)
if use_channel_router and self._router and model in router_model_names:
return self._router.completion(**effective_kwargs)
if self._router and model == config.litellm_model and not use_channel_router:
return self._router.completion(**effective_kwargs)
keys = get_api_keys_for_model(model, config)
if keys:
effective_kwargs["api_key"] = keys[0]
effective_kwargs.update(extra_litellm_params(model, config))
return litellm.completion(**effective_kwargs)
def _normalize_usage(
self,
usage_obj: Any,
*,
model: str = "",
provider: Optional[str] = None,
messages: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""Normalize usage objects from LiteLLM responses/chunks."""
if not usage_obj:
usage = attach_message_hmacs({}, messages) if messages is not None else {}
return filter_prompt_cache_telemetry(usage, self._get_runtime_config())
usage = normalize_litellm_usage(usage_obj, model=model, provider=provider)
if messages is not None:
usage = attach_message_hmacs(usage, messages)
return filter_prompt_cache_telemetry(usage, self._get_runtime_config())
@staticmethod
def _get_response_field(obj: Any, key: str) -> Any:
"""Read a field from dict-like or object-like LiteLLM payloads."""
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
@staticmethod
def _resolve_configured_response_provider(
configured_model: str,
response_model: str,
model_list: Optional[List[Dict[str, Any]]] = None,
) -> str:
"""Match the actual response model against all deployments of one alias."""
normalized_configured_model = str(configured_model or "").strip()
normalized_response_model = str(response_model or "").strip().lower()
if not normalized_configured_model or not normalized_response_model or not model_list:
return ""
for entry in model_list:
params = entry.get("litellm_params", {}) or {}
model_name = str(entry.get("model_name") or "").strip()
if not model_name:
model_name = str(params.get("model") or "").strip()
if model_name != normalized_configured_model:
continue
deployment_model = str(params.get("model") or "").strip()
if deployment_model.lower() != normalized_response_model:
continue
normalized_deployment_model = deployment_model.lower()
if normalized_deployment_model.startswith("openai/~") or "openrouter" in normalized_deployment_model:
return "openrouter"
_resolved_model, resolved_provider = resolved_model_provider_identity(
deployment_model,
)
if resolved_provider:
return resolved_provider
return ""
def _resolve_response_model_provider(
self,
response: Any,
*,
fallback_provider: Optional[str] = None,
configured_model: str = "",
model_list: Optional[List[Dict[str, Any]]] = None,
) -> Tuple[str, str]:
"""Return the actual response model/provider when LiteLLM exposes them."""
configured_provider = str(fallback_provider or "").strip()
normalized_configured_model = str(configured_model or "").strip()
if normalized_configured_model:
resolved_configured_model, _ = resolved_model_provider_identity(
normalized_configured_model,
model_list,
)
configured_route = str(resolved_configured_model or normalized_configured_model).strip().lower()
if configured_route.startswith("openai/~") or "openrouter" in configured_route:
configured_provider = "openrouter"
response_model = str(self._get_response_field(response, "model") or "").strip()
if response_model:
if "/" not in response_model:
return response_model, configured_provider
matched_provider = self._resolve_configured_response_provider(
normalized_configured_model,
response_model,
model_list,
)
if matched_provider:
return response_model, matched_provider
if configured_provider == "openrouter":
return response_model, configured_provider
response_provider = get_explicit_llm_channel_model_provider(response_model)
if response_provider:
return response_model, response_provider
return response_model, configured_provider
return "", configured_provider
@staticmethod
def _promote_error_identity(details: Any) -> Dict[str, str]:
"""Lift route/model diagnostics from nested GenerationError details."""
if not isinstance(details, dict):
return {}
promoted: Dict[str, str] = {}
for key in ("last_model", "route_name", "last_provider"):
candidate = str(details.get(key) or "").strip()
if candidate:
promoted[key] = candidate
return promoted
def _resolve_router_failure_identity(
self,
exc: Any,
*,
route_name: str,
recovery_model_list: List[Dict[str, Any]],
) -> Tuple[str, str]:
"""Resolve the final Router deployment identity from a transport exception."""
normalized_route_name = str(route_name or "").strip()
origins = route_deployment_origins(recovery_model_list, normalized_route_name)
deployment_count = len(origins.hermes_deployments) + len(origins.non_hermes_deployments)
candidate_models: List[str] = []
candidate_provider = ""
seen_payloads: set[int] = set()
def _remember_model(value: Any) -> None:
normalized = str(value or "").strip()
if not normalized:
return
if deployment_count > 1 and normalized == normalized_route_name:
return
if normalized not in candidate_models:
candidate_models.append(normalized)
def _remember_provider(value: Any) -> None:
nonlocal candidate_provider
normalized = str(value or "").strip()
if normalized and not candidate_provider:
candidate_provider = normalized
def _walk(payload: Any) -> None:
if payload is None:
return
payload_id = id(payload)
if payload_id in seen_payloads:
return
seen_payloads.add(payload_id)
if isinstance(payload, dict):
params = payload.get("litellm_params")
if isinstance(params, dict):
_remember_model(params.get("model"))
_remember_provider(
params.get("custom_llm_provider") or params.get("provider")
)
for key in (
"litellm_model",
"response_model",
"deployment_model",
"model",
"model_name",
):
_remember_model(payload.get(key))
_remember_provider(
payload.get("llm_provider")
or payload.get("litellm_provider")
or payload.get("custom_llm_provider")
or payload.get("provider")
)
for key in ("response", "error", "details", "metadata", "body"):
_walk(payload.get(key))
return
for key in ("response", "error", "details", "metadata", "body"):
nested = getattr(payload, key, None)
if nested is not payload:
_walk(nested)
_remember_provider(
getattr(payload, "llm_provider", None)
or getattr(payload, "litellm_provider", None)
or getattr(payload, "custom_llm_provider", None)
or getattr(payload, "provider", None)
)
for key in (
"litellm_model",
"response_model",
"deployment_model",
"model",
"model_name",
):
_remember_model(getattr(payload, key, None))
_walk(exc)
for candidate_model in candidate_models:
resolved_model, resolved_provider = resolved_model_provider_identity(
candidate_model,
recovery_model_list,
)
normalized_route = str(resolved_model or candidate_model).strip().lower()
explicit_provider = get_explicit_llm_channel_model_provider(candidate_model)
route_text = f"{candidate_provider} {normalized_route}".strip().lower()
if normalized_route.startswith("openai/~") or "openrouter" in route_text:
return resolved_model or candidate_model, "openrouter"
if candidate_provider and not explicit_provider:
return resolved_model or candidate_model, candidate_provider
if resolved_provider:
return resolved_model or candidate_model, resolved_provider
return "", candidate_provider
def _extract_text_blocks(self, blocks: Any, *, strip: bool = True) -> str:
"""Extract final-answer text from OpenAI-compatible content blocks.
Some reasoning models (including MiniMax) expose thinking and final
answer blocks in the same list. Thinking blocks can also carry a
``text`` field, so concatenating every block corrupts structured output
by prefixing the JSON answer with chain-of-thought text.
"""
if not blocks:
return ""
parts: List[str] = []
for block in blocks:
if isinstance(block, str):
parts.append(block)
continue
block_type = ""
text = None
if isinstance(block, dict):
block_type = str(block.get("type") or "").strip().lower()
text = block.get("text")
if text is None:
text = block.get("content")
else:
block_type = str(getattr(block, "type", "") or "").strip().lower()
text = getattr(block, "text", None)
if text is None:
text = getattr(block, "content", None)
# Keep untyped legacy blocks for compatibility, but typed blocks
# must explicitly represent final output text.
if block_type and block_type not in {"text", "output_text"}:
continue
if isinstance(text, str) and text:
parts.append(text)
result = "".join(parts)
return result.strip() if strip else result
def _extract_completion_text(self, response: Any) -> str:
"""Extract text from non-stream LiteLLM completion responses."""
choices = self._get_response_field(response, "choices")
if not choices:
return ""
choice = choices[0]
message = self._get_response_field(choice, "message")
content_blocks = self._get_response_field(choice, "content_blocks")
if content_blocks is None and message is not None:
content_blocks = self._get_response_field(message, "content_blocks")
block_text = self._extract_text_blocks(content_blocks)
if block_text:
return strip_leading_think_wrapper(block_text)
content = None
if message is not None:
content = self._get_response_field(message, "content")
if content is None:
content = self._get_response_field(choice, "content")
if isinstance(content, list):
return strip_leading_think_wrapper(self._extract_text_blocks(content))
if isinstance(content, str):
return strip_leading_think_wrapper(content)
return str(content).strip() if content is not None else ""
def _extract_stream_text(self, chunk: Any) -> str:
"""Extract provider-agnostic text delta from a LiteLLM streaming chunk."""
choices = chunk.get("choices") if isinstance(chunk, dict) else getattr(chunk, "choices", None)
if not choices:
return ""
choice = choices[0]
delta = choice.get("delta") if isinstance(choice, dict) else getattr(choice, "delta", None)
message = choice.get("message") if isinstance(choice, dict) else getattr(choice, "message", None)
content: Any = None
if isinstance(delta, dict):
content = delta.get("content")
elif isinstance(delta, str):
content = delta
elif delta is not None:
content = getattr(delta, "content", None)
if content is None:
if isinstance(message, dict):
content = message.get("content")
elif message is not None:
content = getattr(message, "content", None)
if isinstance(content, list):
return self._extract_text_blocks(content, strip=False)
return content if isinstance(content, str) else ""
def _consume_litellm_stream(
self,
stream_response: Any,
*,
model: str,
usage_model: Optional[str] = None,
provider: Optional[str] = None,
progress_callback: Optional[Callable[[int], None]] = None,
) -> Tuple[str, Dict[str, Any]]:
"""Consume a LiteLLM stream into a single text payload."""
chunks: List[str] = []
usage: Dict[str, Any] = {}
chars_received = 0
next_emit_at = 1
try:
for chunk in stream_response:
chunk_usage = extract_usage_payload(chunk)
normalized_usage = self._normalize_usage(
chunk_usage,
model=usage_model or model,
provider=provider,
)
if normalized_usage:
usage = normalized_usage
delta_text = self._extract_stream_text(chunk)
if not delta_text:
continue
chunks.append(delta_text)
chars_received += len(delta_text)
if progress_callback and chars_received >= next_emit_at:
progress_callback(chars_received)
next_emit_at = chars_received + 160
except Exception as exc:
raise _LiteLLMStreamError(
f"{model} stream interrupted: {exc}",
partial_received=chars_received > 0,
) from exc
response_text = strip_leading_think_wrapper("".join(chunks))
if not response_text:
raise _LiteLLMStreamError(
f"{model} stream returned empty response",
partial_received=False,
)
if progress_callback and chars_received > 0:
progress_callback(chars_received)
return response_text, usage
def _get_generation_backend(self, backend_id: Optional[str] = None) -> GenerationBackend:
"""Return the configured generation backend."""
config = self._get_runtime_config()
resolved_backend_id = backend_id or self._resolve_generation_backend_config()[0]
return create_generation_backend(
resolved_backend_id,
config=config,
litellm_completion_callable=self._call_litellm_impl,
)
def _call_litellm(
self,
prompt: str,
generation_config: dict,
*,
system_prompt: Optional[str] = None,
stream: bool = False,
stream_progress_callback: Optional[Callable[[int], None]] = None,
response_validator: Optional[Callable[[str], None]] = None,
audit_context: Optional[Dict[str, Any]] = None,
return_generation_result: bool = False,
) -> Union[Tuple[str, str, Dict[str, Any]], GenerationResult]:
"""Compatibility wrapper around the configured generation backend."""
preflight_error = self.get_generation_backend_config_error()
if preflight_error is not None and not self._can_use_generation_fallback(preflight_error):
raise preflight_error
backend_id, fallback_backend_id = self._resolve_generation_backend_config()
try:
result = self._get_generation_backend(backend_id).generate(
prompt,
generation_config,
system_prompt=system_prompt,
stream=stream,
stream_progress_callback=stream_progress_callback,
response_validator=response_validator,
audit_context=audit_context,
)
except GenerationError as exc:
if not exc.fallbackable or not fallback_backend_id:
raise
try:
fallback_backend = self._get_generation_backend(fallback_backend_id)
except GenerationError as fallback_exc:
raise GenerationError(
error_code=fallback_exc.error_code,
stage="fallback",
retryable=False,
fallbackable=False,
backend=fallback_backend_id,
provider=fallback_exc.provider,
details={
"primary_error": {
"error_code": exc.error_code.value,
"backend": exc.backend,
"provider": exc.provider,
"stage": exc.stage,
"details": exc.details,
},
"fallback_error": fallback_exc.details,
},
) from fallback_exc
try:
result = fallback_backend.generate(
prompt,
generation_config,
system_prompt=system_prompt,
stream=stream,
stream_progress_callback=stream_progress_callback,
response_validator=response_validator,
audit_context=audit_context,
)
except _AllModelsFailedError:
raise
except GenerationError as fallback_exc:
fallback_identity = self._promote_error_identity(fallback_exc.details)
raise GenerationError(
error_code=fallback_exc.error_code,
stage="fallback",
retryable=False,
fallbackable=False,
backend=fallback_backend_id,
provider=fallback_exc.provider,
details={
"reason": "fallback_backend_failed",
**fallback_identity,
"primary_error": {
"error_code": exc.error_code.value,
"backend": exc.backend,
"provider": exc.provider,
"stage": exc.stage,
"details": exc.details,
},
"fallback_error": {
"error_code": fallback_exc.error_code.value,
"backend": fallback_exc.backend,
"provider": fallback_exc.provider,
"stage": fallback_exc.stage,
"details": fallback_exc.details,
},
},
) from fallback_exc
except Exception as fallback_exc:
raise GenerationError(
error_code=GenerationErrorCode.UNKNOWN_BACKEND_ERROR,
stage="fallback",
retryable=False,
fallbackable=False,
backend=fallback_backend_id,
provider=fallback_backend_id,
details={
"reason": "fallback_backend_failed",
"primary_error": {
"error_code": exc.error_code.value,
"backend": exc.backend,
"provider": exc.provider,
"stage": exc.stage,
"details": exc.details,
},
"fallback_error": str(fallback_exc),
},
) from fallback_exc
if return_generation_result:
return result
return result.text, result.model, result.usage
def _call_litellm_impl(
self,
prompt: str,
generation_config: dict,
*,
system_prompt: Optional[str] = None,
stream: bool = False,
stream_progress_callback: Optional[Callable[[int], None]] = None,
response_validator: Optional[Callable[[str], None]] = None,
audit_context: Optional[Dict[str, Any]] = None,
) -> Tuple[str, str, Dict[str, Any]]:
"""Call LLM via litellm with fallback across configured models.
When channels/YAML are configured, every model goes through the Router
(which handles per-model key selection, load balancing, and retries).
In legacy mode, the primary model may use the Router while fallback
models fall back to direct litellm.completion().
Args:
prompt: User prompt text.
generation_config: Dict with optional keys: temperature, max_output_tokens, max_tokens.
response_validator: Optional callable that accepts the raw response text and raises
an exception if the response is unacceptable (e.g. not valid JSON). When it
raises, the current model is treated as failed and the next fallback model is
tried. If all models fail validation, :class:`_AllModelsFailedError` is raised
with ``last_response_text`` set to the last raw response received.
Returns:
Tuple of (response text, model_used, usage). On success model_used is the full model
name and usage is a dict with prompt_tokens, completion_tokens, total_tokens.
"""
config = self._get_runtime_config()
max_tokens = (
generation_config.get('max_output_tokens')
or generation_config.get('max_tokens')
or 8192
)
requested_temperature = generation_config.get('temperature', 0.7)
requested_timeout = generation_config.get("timeout")
models_to_try = [config.litellm_model] + (config.litellm_fallback_models or [])
models_to_try = [m for m in models_to_try if m]
use_channel_router = self._has_channel_config(config)
last_error = None
last_response_text: Optional[str] = None
last_model: Optional[str] = None
last_provider: Optional[str] = None
last_usage: Dict[str, Any] = {}
effective_system_prompt = system_prompt or self.TEXT_SYSTEM_PROMPT
router_model_names = set(get_configured_llm_models(config.llm_model_list))
for model in models_to_try:
last_model = model
origins = route_deployment_origins(config.llm_model_list, model)
model_stream = bool(stream and not origins.has_hermes)
recovery_model_list = config.llm_model_list
legacy_router_model_list = getattr(self, "_legacy_router_model_list", None) or []
if legacy_router_model_list and model == config.litellm_model and not use_channel_router:
recovery_model_list = legacy_router_model_list
usage_model, usage_provider = resolved_model_provider_identity(model, recovery_model_list)
if usage_provider:
last_provider = usage_provider
try:
def _attach_usage_audit(
usage: Dict[str, Any],
messages: List[Dict[str, Any]],
) -> Dict[str, Any]:
if audit_context is None:
return filter_prompt_cache_telemetry(
attach_message_hmacs(usage, messages),
config,
)
effective_audit_context = dict(audit_context)
effective_audit_context["provider"] = (
usage.get("provider") or usage_provider
)
effective_audit_context["transport"] = (
effective_audit_context.get("transport") or "litellm"
)
return filter_prompt_cache_telemetry(
attach_legacy_message_stability_audit(
usage,
messages,
effective_audit_context,
),
config,
)
model_short = model.split("/")[-1] if "/" in model else model
extra = get_thinking_extra_body(model_short)
call_kwargs: Dict[str, Any] = {
"model": model,
"messages": [
{"role": "system", "content": effective_system_prompt},
{"role": "user", "content": prompt},
],
"max_tokens": max_tokens,
}
if requested_timeout not in (None, ""):
call_kwargs["timeout"] = requested_timeout
if extra:
call_kwargs["extra_body"] = extra
uses_router = (
(use_channel_router and self._router and model in router_model_names)
or (self._router and model == config.litellm_model and not use_channel_router)
)
if not uses_router:
try:
keys = get_api_keys_for_model(model, config)
except AttributeError:
keys = []
if keys:
call_kwargs["api_key"] = keys[0]
try:
call_kwargs.update(extra_litellm_params(model, config))
except AttributeError:
pass
call_kwargs = apply_litellm_generation_params(
call_kwargs,
model,
requested_temperature,
model_list=recovery_model_list,
)
route_context = build_provider_cache_route_context(
model=model,
provider=usage_provider,
call_kwargs=call_kwargs,
model_list=recovery_model_list,
call_type="analysis",
)
hint_result = apply_prompt_cache_hints(call_kwargs, route_context, config)
call_kwargs = hint_result.call_kwargs
if requested_timeout not in (None, ""):
call_kwargs["timeout"] = requested_timeout
if hint_result.diagnostics:
logger.debug("[PromptCache] %s", hint_result.diagnostics)
_stream_text: Optional[str] = None
_stream_usage: Dict[str, Any] = {}
if model_stream:
try:
stream_response = call_litellm_with_param_recovery(
lambda kwargs: self._dispatch_litellm_completion(
model,
kwargs,
config=config,
use_channel_router=use_channel_router,
router_model_names=router_model_names,
),
model=model,
call_kwargs={**call_kwargs, "stream": True},
model_list=recovery_model_list,
cache_recovery=False,
logger=logger,
)
_stream_text, _stream_usage = self._consume_litellm_stream(
stream_response,
model=model,
usage_model=usage_model,
provider=usage_provider,
progress_callback=stream_progress_callback,
)
except _LiteLLMStreamError as exc:
safe_error = self._sanitize_litellm_exception_text(exc, config=config, model=model)
if exc.partial_received:
logger.warning(
"[LiteLLM] %s stream failed after partial output, retrying non-stream for same model: %s",
model,
safe_error,
)
else:
logger.warning(
"[LiteLLM] %s stream unavailable before first chunk, falling back to non-stream: %s",
model,
safe_error,
)
last_error = RuntimeError(f"{type(exc).__name__}: {safe_error}")
except Exception as exc:
safe_error = self._sanitize_litellm_exception_text(exc, config=config, model=model)
logger.warning(
"[LiteLLM] %s stream request failed before first chunk, falling back to non-stream: %s",
model,
safe_error,
)
if _stream_text is not None:
last_response_text = _stream_text
last_model = model
if usage_provider:
_stream_usage["provider"] = usage_provider
_stream_usage = _attach_usage_audit(_stream_usage, call_kwargs["messages"])
if usage_provider:
_stream_usage.setdefault("provider", usage_provider)
last_usage = _stream_usage
if response_validator is not None:
response_validator(_stream_text)
return _stream_text, model, _stream_usage
response = call_litellm_with_param_recovery(
lambda kwargs: self._dispatch_litellm_completion(
model,
kwargs,
config=config,
use_channel_router=use_channel_router,
router_model_names=router_model_names,
),
model=model,
call_kwargs=call_kwargs,
model_list=recovery_model_list,
logger=logger,
)
response_model, response_provider = self._resolve_response_model_provider(
response,
fallback_provider=usage_provider,
configured_model=model,
model_list=recovery_model_list,
)
actual_model = response_model or model
if response_model:
last_model = actual_model
if response_provider:
last_provider = response_provider
content = self._extract_completion_text(response)
if content:
usage_messages = None if audit_context is not None else call_kwargs["messages"]
usage = self._normalize_usage(
extract_usage_payload(response),
model=response_model or usage_model or model,
provider=response_provider or usage_provider,
messages=usage_messages,
)
if response_provider or usage_provider:
usage["provider"] = response_provider or usage_provider
if audit_context is not None:
usage = _attach_usage_audit(usage, call_kwargs["messages"])
if response_model:
usage.setdefault("response_model", response_model)
if response_provider or usage_provider:
usage.setdefault("provider", response_provider or usage_provider)
last_response_text = content
last_model = actual_model
if response_provider:
last_provider = response_provider
last_usage = usage
if response_validator is not None:
response_validator(content)
return (content, actual_model, usage)
raise ValueError("LLM returned empty response")
except Exception as e:
if uses_router:
router_model, router_provider = self._resolve_router_failure_identity(
e,
route_name=model,
recovery_model_list=recovery_model_list,
)
if router_model:
last_model = router_model
if router_provider:
last_provider = router_provider
safe_error = self._sanitize_litellm_exception_text(e, config=config, model=model)
logger.warning("[LiteLLM] %s failed: %s", model, safe_error)
last_error = RuntimeError(f"{type(e).__name__}: {safe_error}")
continue
raise _AllModelsFailedError(
f"All LLM models failed (tried {len(models_to_try)} model(s)). Last error: {last_error}",
last_response_text=last_response_text,
last_model=last_model,
last_provider=last_provider,
last_usage=last_usage,
)
def generate_text(
self,
prompt: str,
max_tokens: int = 2048,
temperature: float = 0.7,
) -> Optional[str]:
"""Public entry point for free-form text generation.
External callers (e.g. MarketAnalyzer) must use this method instead of
calling _call_litellm() directly or accessing private attributes such as
_litellm_available, _router, _model, _use_openai, or _use_anthropic.
Args:
prompt: Text prompt to send to the LLM.
max_tokens: Maximum tokens in the response (default 2048).
temperature: Sampling temperature (default 0.7).
Returns:
Response text, or None if the LLM call fails (error is logged).
"""
try:
result = self._call_litellm(
prompt,
generation_config={"max_tokens": max_tokens, "temperature": temperature},
)
if isinstance(result, tuple):
text, model_used, usage = result
if should_persist_usage_telemetry(usage):
persist_llm_usage(usage, model_used, call_type="market_review")
return text
return result
except GenerationError:
raise
except Exception as exc:
logger.error("[generate_text] LLM call failed: %s", exc)
return None
def get_generation_backend_identity(self) -> Tuple[str, str]:
"""Return the configured primary backend identity for live diagnostics."""
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
return backend_id, backend_id
config = self._get_runtime_config()
return backend_id, str(getattr(config, "litellm_model", "") or "")
def generate_text_with_metadata(
self,
prompt: str,
max_tokens: int = 2048,
temperature: float = 0.7,
) -> Optional[GenerationResult]:
"""Generate text and return the actual backend/model used for diagnostics."""
try:
result = self._call_litellm(
prompt,
generation_config={"max_tokens": max_tokens, "temperature": temperature},
return_generation_result=True,
)
if not isinstance(result, GenerationResult):
raise TypeError("generation backend returned an invalid result")
if should_persist_usage_telemetry(result.usage):
persist_llm_usage(result.usage, result.model, call_type="market_review")
return result
except GenerationError:
raise
except _AllModelsFailedError as exc:
backend_id, fallback_backend_id = self._resolve_generation_backend_config()
if not fallback_backend_id and backend_id == LITELLM_BACKEND_ID:
logger.warning(
"[generate_text_with_metadata] Primary LiteLLM exhausted all configured models; "
"returning empty GenerationResult so caller fallback can continue"
)
usage = dict(exc.last_usage or {})
if exc.last_provider:
usage.setdefault("provider", exc.last_provider)
return GenerationResult(
text="",
model=exc.last_model or str(getattr(self._get_runtime_config(), "litellm_model", "") or ""),
provider=exc.last_provider or backend_id,
backend=backend_id,
usage=usage,
diagnostics={
"reason": "all_models_failed",
"configured_primary_backend": backend_id,
"configured_fallback_backend": fallback_backend_id,
"last_model": exc.last_model,
"template_fallback": True,
},
)
failed_backend = fallback_backend_id or backend_id
raise GenerationError(
error_code=GenerationErrorCode.UNKNOWN_BACKEND_ERROR,
stage="fallback" if fallback_backend_id else "generation",
retryable=False,
fallbackable=False,
backend=failed_backend,
provider=exc.last_provider or failed_backend,
details={
"reason": "all_models_failed",
"configured_primary_backend": backend_id,
"configured_fallback_backend": fallback_backend_id,
"last_model": exc.last_model,
},
) from exc
except Exception as exc:
logger.error("[generate_text_with_metadata] LLM call failed: %s", exc)
raise
def analyze(
self,
context: Dict[str, Any],
news_context: Optional[str] = None,
progress_callback: Optional[Callable[[int, str], None]] = None,
stream_progress_callback: Optional[Callable[[int], None]] = None,
analysis_context_pack_summary: Optional[str] = None,
) -> AnalysisResult:
"""
分析单只股票
流程:
1. 格式化输入数据(技术面 + 新闻)
2. 调用 Gemini API带重试和模型切换
3. 解析 JSON 响应
4. 返回结构化结果
Args:
context: 从 storage.get_analysis_context() 获取的上下文数据
news_context: 预先搜索的新闻内容(可选)
Returns:
AnalysisResult 对象
"""
def _emit_progress(progress: int, message: str) -> None:
if progress_callback is None:
return
try:
progress_callback(progress, message)
except Exception as exc:
logger.debug("[analyzer] progress callback skipped: %s", exc)
code = context.get('code', 'Unknown')
config = self._get_runtime_config()
report_language = normalize_report_language(getattr(config, "report_language", "zh"))
system_prompt = self._get_analysis_system_prompt(report_language, stock_code=code)
skill_instructions, default_skill_policy, use_legacy_default_prompt = self._get_skill_prompt_sections()
# 请求前增加延时(防止连续请求触发限流)
request_delay = config.gemini_request_delay
if request_delay > 0:
logger.debug(f"[LLM] 请求前等待 {request_delay:.1f} 秒...")
_emit_progress(65, f"{code}LLM 请求前等待 {request_delay:.1f}")
time.sleep(request_delay)
# 优先从上下文获取股票名称(由 main.py 传入)
name = context.get('stock_name')
if not name or name.startswith('股票'):
# 备选:从 realtime 中获取
if 'realtime' in context and context['realtime'].get('name'):
name = context['realtime']['name']
else:
# 最后从映射表获取
name = STOCK_NAME_MAP.get(code, f'股票{code}')
backend_error = self.get_generation_backend_config_error()
if backend_error is not None and not self._can_use_generation_fallback(backend_error):
details = backend_error.details or {}
field = str(details.get("field") or "GENERATION_BACKEND")
requested_backend = str(details.get("requested_backend") or backend_error.backend)
reason = str(details.get("reason") or backend_error.error_code.value)
if report_language == "en":
summary = (
"AI analysis is unavailable because the generation backend "
f"cannot start: {backend_error.error_code.value}."
)
risk_warning = (
f"Check {field}={requested_backend} ({reason}) or set a valid "
"backend/fallback before retrying."
)
elif report_language == "ko":
summary = (
"생성 백엔드를 시작할 수 없어 AI 분석을 사용할 수 없습니다: "
f"{backend_error.error_code.value}."
)
risk_warning = (
f"{field}={requested_backend} ({reason})를 확인하거나 유효한 "
"백엔드/폴백을 설정한 뒤 다시 시도하세요."
)
else:
summary = (
"AI 分析功能不可用:生成后端无法启动,"
f"{backend_error.error_code.value}"
)
risk_warning = (
f"请检查 {field}={requested_backend}{reason}"
"或配置有效后端/回退后重试。"
)
return AnalysisResult(
code=code,
name=name,
sentiment_score=50,
trend_prediction=localize_trend_prediction('震荡', report_language),
operation_advice=localize_operation_advice('持有', report_language),
confidence_level=localize_confidence_level('', report_language),
analysis_summary=summary,
risk_warning=risk_warning,
success=False,
error_message=(
f"{backend_error.error_code.value}: {field}={requested_backend}"
),
model_used=None,
report_language=report_language,
)
# 如果模型不可用,返回默认结果
if not self.is_available():
return AnalysisResult(
code=code,
name=name,
sentiment_score=50,
trend_prediction=localize_trend_prediction('震荡', report_language),
operation_advice=localize_operation_advice('持有', report_language),
confidence_level=localize_confidence_level('', report_language),
analysis_summary=_localized_text(
report_language,
en='AI analysis is unavailable because no API key is configured.',
zh='AI 分析功能未启用(未配置 API Key',
ko='API 키가 설정되지 않아 AI 분석을 사용할 수 없습니다.',
),
risk_warning=_localized_text(
report_language,
en='Configure an LLM API key (GEMINI_API_KEY/ANTHROPIC_API_KEY/OPENAI_API_KEY) and retry.',
zh='请配置 LLM API KeyGEMINI_API_KEY/ANTHROPIC_API_KEY/OPENAI_API_KEY后重试',
ko='LLM API 키(GEMINI_API_KEY/ANTHROPIC_API_KEY/OPENAI_API_KEY)를 설정한 뒤 다시 시도하세요.',
),
success=False,
error_message=_localized_text(
report_language,
en='LLM API key is not configured',
zh='LLM API Key 未配置',
ko='LLM API 키가 설정되지 않았습니다',
),
model_used=None,
report_language=report_language,
)
try:
# 格式化输入(包含技术面数据和新闻)
prompt = self._format_prompt(
context,
name,
news_context,
report_language=report_language,
analysis_context_pack_summary=analysis_context_pack_summary,
)
legacy_audit_context = {
"language": report_language,
"market_group": _legacy_market_group(code),
"analysis_mode": "stock_analysis",
"legacy_prompt_mode": "legacy_default" if use_legacy_default_prompt else "skill_aware",
"skill_config": {
"skill_instructions": skill_instructions,
"default_skill_policy": default_skill_policy,
"use_legacy_default_prompt": use_legacy_default_prompt,
},
"transport": "litellm",
"dynamic_markers": _legacy_audit_marker_specs(
context,
code=code,
stock_name=name,
report_language=report_language,
news_context=news_context,
analysis_context_pack_summary=analysis_context_pack_summary,
),
}
config = self._get_runtime_config()
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
model_name = config.litellm_model or "unknown"
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
model_name = backend_id
legacy_audit_context["transport"] = backend_id
logger.info(f"========== AI 分析 {name}({code}) ==========")
logger.info(f"[LLM配置] 模型: {model_name}")
logger.info(f"[LLM配置] Prompt 长度: {len(prompt)} 字符")
logger.info(f"[LLM配置] 是否包含新闻: {'' if news_context else ''}")
# 本地 CLI backend 是进程执行能力,不记录完整 prompt。
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
prompt_preview = redact_diagnostic_text(prompt, limit=500)
else:
prompt_preview = prompt[:500] + "..." if len(prompt) > 500 else prompt
logger.info(f"[LLM Prompt 预览]\n{prompt_preview}")
if backend_id not in LOCAL_CLI_GENERATION_BACKEND_IDS:
logger.debug(f"=== 完整 Prompt ({len(prompt)}字符) ===\n{prompt}\n=== End Prompt ===")
# 设置生成配置
generation_config = {
"temperature": config.llm_temperature,
"max_output_tokens": 8192,
}
logger.info(f"[LLM调用] 开始调用 {model_name}...")
_emit_progress(68, f"{name}LLM 已接收请求,等待响应")
# 使用 litellm 调用(支持完整性校验重试)
current_prompt = prompt
retry_count = 0
max_retries = config.report_integrity_retry if config.report_integrity_enabled else 0
while True:
start_time = time.time()
try:
response_text, model_used, llm_usage = self._call_litellm(
current_prompt,
generation_config,
system_prompt=system_prompt,
stream=True,
stream_progress_callback=stream_progress_callback,
response_validator=self._validate_json_response,
audit_context=legacy_audit_context,
)
except _AllModelsFailedError as exc:
if exc.last_response_text is not None:
logger.warning(
"[LLM JSON] %s(%s): all models returned invalid JSON, using text fallback",
name,
code,
)
response_text = exc.last_response_text
model_used = exc.last_model
llm_usage = exc.last_usage
else:
raise
elapsed = time.time() - start_time
# 记录响应信息
logger.info(
f"[LLM返回] {model_name} 响应成功, 耗时 {elapsed:.2f}s, 响应长度 {len(response_text)} 字符"
)
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
response_preview = redact_diagnostic_text(response_text, limit=300)
else:
response_preview = response_text[:300] + "..." if len(response_text) > 300 else response_text
logger.info(f"[LLM返回 预览]\n{response_preview}")
if backend_id not in LOCAL_CLI_GENERATION_BACKEND_IDS:
logger.debug(
f"=== {model_name} 完整响应 ({len(response_text)}字符) ===\n{response_text}\n=== End Response ==="
)
# Keep parser/retry progress monotonic so task progress/message never "goes backward".
parse_progress = min(99, 93 + retry_count * 2)
_emit_progress(parse_progress, f"{name}LLM 返回完成,正在解析 JSON")
# 解析响应
result = self._parse_response(response_text, code, name)
result.raw_response = response_text
result.search_performed = bool(news_context)
result.market_snapshot = self._build_market_snapshot(context)
result.model_used = model_used
result.report_language = report_language
normalize_chip_structure_availability(result, context.get("chip"))
# 内容完整性校验(可选)
if not config.report_integrity_enabled:
break
require_phase_decision = isinstance(context.get("market_phase_context"), dict)
pass_integrity, missing_fields = self._check_content_integrity(
result,
require_phase_decision=require_phase_decision,
)
if pass_integrity:
break
if retry_count < max_retries:
current_prompt = self._build_integrity_retry_prompt(
prompt,
response_text,
missing_fields,
report_language=report_language,
)
retry_count += 1
logger.info(
"[LLM完整性] 必填字段缺失 %s,第 %d 次补全重试",
missing_fields,
retry_count,
)
retry_progress = min(99, 92 + retry_count * 2)
_emit_progress(
retry_progress,
f"{name}:报告字段不完整,正在补全重试({retry_count}/{max_retries}",
)
else:
self._apply_placeholder_fill(result, missing_fields)
logger.warning(
"[LLM完整性] 必填字段缺失 %s,已占位补全,不阻塞流程",
missing_fields,
)
break
if should_persist_usage_telemetry(llm_usage):
persist_llm_usage(llm_usage, model_used, call_type="analysis", stock_code=code)
logger.info(f"[LLM解析] {name}({code}) 分析完成: {result.trend_prediction}, 评分 {result.sentiment_score}")
return result
except Exception as e:
safe_error = self._sanitize_hermes_exception_text(e)
logger.error("AI 分析 %s(%s) 失败: %s", name, code, safe_error)
return AnalysisResult(
code=code,
name=name,
sentiment_score=50,
trend_prediction=localize_trend_prediction('震荡', report_language),
operation_advice=localize_operation_advice('持有', report_language),
confidence_level=localize_confidence_level('', report_language),
analysis_summary=_localized_text(
report_language,
en=f'Analysis failed: {safe_error[:100]}',
zh=f'分析过程出错: {safe_error[:100]}',
ko=f'분석 중 오류가 발생했습니다: {safe_error[:100]}',
),
risk_warning=_localized_text(
report_language,
en='Analysis failed. Please retry later or review manually.',
zh='分析失败,请稍后重试或手动分析',
ko='분석에 실패했습니다. 잠시 후 다시 시도하거나 수동으로 검토하세요.',
),
success=False,
error_message=safe_error,
model_used=None,
report_language=report_language,
)
def _format_prompt(
self,
context: Dict[str, Any],
name: str,
news_context: Optional[str] = None,
report_language: str = "zh",
analysis_context_pack_summary: Optional[str] = None,
) -> str:
"""
格式化分析提示词(决策仪表盘 v2.0
包含:技术指标、实时行情(量比/换手率)、筹码分布、趋势分析、新闻
Args:
context: 技术面数据上下文(包含增强数据)
name: 股票名称(默认值,可能被上下文覆盖)
news_context: 预先搜索的新闻内容
"""
code = context.get('code', 'Unknown')
report_language = normalize_report_language(report_language)
_, _, use_legacy_default_prompt = self._get_skill_prompt_sections()
# 优先使用上下文中的股票名称(从 realtime_quote 获取)
stock_name = context.get('stock_name', name)
if not stock_name or stock_name == f'股票{code}':
stock_name = STOCK_NAME_MAP.get(code, f'股票{code}')
today = context.get('today', {})
unknown_text = get_unknown_text(report_language)
no_data_text = get_no_data_text(report_language)
quote_section_title, close_price_label = _phase_aware_quote_labels(context)
hide_regular_session_ohlc = _should_hide_regular_session_ohlc(context)
realtime_overlay_quote = hide_regular_session_ohlc and _today_has_realtime_overlay(today)
pct_chg_label = "实时涨跌幅" if realtime_overlay_quote else "涨跌幅"
volume_label = "实时成交量" if realtime_overlay_quote else "成交量"
amount_label = "实时成交额" if realtime_overlay_quote else "成交额"
quote_rows = [
f"| {close_price_label} | {today.get('close', 'N/A')} 元 |",
]
if not hide_regular_session_ohlc:
quote_rows.extend(
[
f"| 开盘价 | {today.get('open', 'N/A')} 元 |",
f"| 最高价 | {today.get('high', 'N/A')} 元 |",
f"| 最低价 | {today.get('low', 'N/A')} 元 |",
]
)
quote_rows.extend(
[
f"| {pct_chg_label} | {today.get('pct_chg', 'N/A')}% |",
f"| {volume_label} | {self._format_volume(today.get('volume'))} |",
f"| {amount_label} | {self._format_amount(today.get('amount'))} |",
]
)
quote_rows_text = "\n".join(quote_rows)
# ========== 构建决策仪表盘格式的输入 ==========
prompt = f"""# 决策仪表盘分析请求
## 📊 股票基础信息
| 项目 | 数据 |
|------|------|
| 股票代码 | **{code}** |
| 股票名称 | **{stock_name}** |
| 分析日期 | {context.get('date', unknown_text)} |
---
"""
prompt += format_market_phase_prompt_section(
context.get("market_phase_context"),
report_language=report_language,
)
daily_market_context_section = format_daily_market_context_prompt_section(
context.get("daily_market_context"),
report_language=report_language,
)
if daily_market_context_section:
prompt += daily_market_context_section
market_structure_section = format_market_structure_prompt_section(
context.get("market_structure_context"),
report_language=report_language,
)
if market_structure_section:
prompt += market_structure_section
if isinstance(analysis_context_pack_summary, str) and analysis_context_pack_summary:
prompt += analysis_context_pack_summary
prompt += f"""
## 📈 技术面数据
### {quote_section_title}
| 指标 | 数值 |
|------|------|
{quote_rows_text}
### 均线系统(关键判断指标)
| 均线 | 数值 | 说明 |
|------|------|------|
| MA5 | {today.get('ma5', 'N/A')} | 短期趋势线 |
| MA10 | {today.get('ma10', 'N/A')} | 中短期趋势线 |
| MA20 | {today.get('ma20', 'N/A')} | 中期趋势线 |
| 均线形态 | {context.get('ma_status', unknown_text)} | 多头/空头/缠绕 |
"""
# 添加实时行情数据(量比、换手率等)
if 'realtime' in context:
rt = context['realtime']
prompt += f"""
### 实时行情增强数据
| 指标 | 数值 | 解读 |
|------|------|------|
| 当前价格 | {rt.get('price', 'N/A')} 元 | |
| **量比** | **{rt.get('volume_ratio', 'N/A')}** | {rt.get('volume_ratio_desc', '')} |
| **换手率** | **{rt.get('turnover_rate', 'N/A')}%** | |
| 市盈率(动态) | {rt.get('pe_ratio', 'N/A')} | |
| 市净率 | {rt.get('pb_ratio', 'N/A')} | |
| 总市值 | {self._format_amount(rt.get('total_mv'))} | |
| 流通市值 | {self._format_amount(rt.get('circ_mv'))} | |
| 60日涨跌幅 | {rt.get('change_60d', 'N/A')}% | 中期表现 |
"""
# 添加财报与分红(价值投资口径)
fundamental_context = context.get("fundamental_context") if isinstance(context, dict) else None
earnings_block = (
fundamental_context.get("earnings", {})
if isinstance(fundamental_context, dict)
else {}
)
earnings_data = (
earnings_block.get("data", {})
if isinstance(earnings_block, dict)
else {}
)
financial_report = (
earnings_data.get("financial_report", {})
if isinstance(earnings_data, dict)
else {}
)
dividend_metrics = (
earnings_data.get("dividend", {})
if isinstance(earnings_data, dict)
else {}
)
if isinstance(financial_report, dict) or isinstance(dividend_metrics, dict):
financial_report = financial_report if isinstance(financial_report, dict) else {}
dividend_metrics = dividend_metrics if isinstance(dividend_metrics, dict) else {}
ttm_yield = dividend_metrics.get("ttm_dividend_yield_pct", "N/A")
ttm_cash = dividend_metrics.get("ttm_cash_dividend_per_share", "N/A")
ttm_count = dividend_metrics.get("ttm_event_count", "N/A")
report_date = financial_report.get("report_date", "N/A")
prompt += f"""
### 财报与分红(价值投资口径)
| 指标 | 数值 | 说明 |
|------|------|------|
| 最近报告期 | {report_date} | 来自结构化财报字段 |
| 营业收入 | {financial_report.get('revenue', 'N/A')} | |
| 归母净利润 | {financial_report.get('net_profit_parent', 'N/A')} | |
| 经营现金流 | {financial_report.get('operating_cash_flow', 'N/A')} | |
| ROE | {financial_report.get('roe', 'N/A')} | |
| 近12个月每股现金分红 | {ttm_cash} | 仅现金分红、税前口径 |
| TTM 股息率 | {ttm_yield} | 公式近12个月每股现金分红 / 当前价格 × 100% |
| TTM 分红事件数 | {ttm_count} | |
> 若上述字段为 N/A 或缺失,请明确写“数据缺失,无法判断”,禁止编造。
"""
capital_flow_block = (
fundamental_context.get("capital_flow", {})
if isinstance(fundamental_context, dict)
else {}
)
capital_flow_data = (
capital_flow_block.get("data", {})
if isinstance(capital_flow_block, dict)
else {}
)
stock_flow = (
capital_flow_data.get("stock_flow", {})
if isinstance(capital_flow_data, dict)
else {}
)
sector_flow = (
capital_flow_data.get("sector_rankings", {})
if isinstance(capital_flow_data, dict)
else {}
)
has_capital_flow = (
isinstance(stock_flow, dict)
and any(v is not None for v in stock_flow.values())
) or (
isinstance(sector_flow, dict)
and (sector_flow.get("top") or sector_flow.get("bottom"))
)
if has_capital_flow:
top_sectors = sector_flow.get("top", []) if isinstance(sector_flow, dict) else []
bottom_sectors = sector_flow.get("bottom", []) if isinstance(sector_flow, dict) else []
top_sector_text = "".join(
str(item.get("name", "")).strip()
for item in top_sectors[:3]
if isinstance(item, dict) and str(item.get("name", "")).strip()
) or "N/A"
bottom_sector_text = "".join(
str(item.get("name", "")).strip()
for item in bottom_sectors[:3]
if isinstance(item, dict) and str(item.get("name", "")).strip()
) or "N/A"
prompt += f"""
### 主力资金流向(操作建议过滤器)
| 指标 | 数值 | 决策含义 |
|------|------|----------|
| 主力净流入 | {stock_flow.get('main_net_inflow', 'N/A')} | 正值偏支持,负值偏压制 |
| 5日净流入 | {stock_flow.get('inflow_5d', 'N/A')} | 用于判断资金持续性 |
| 10日净流入 | {stock_flow.get('inflow_10d', 'N/A')} | 用于判断资金持续性 |
| 资金流入靠前板块 | {top_sector_text} | 板块资金共振参考 |
| 资金流出靠前板块 | {bottom_sector_text} | 板块风险参考 |
> 资金流向只能作为价格位置的过滤器:接近压力且主力流出时不得追买;接近支撑且未放量跌破时,优先判断为持有观察、震荡或洗盘观察。
"""
# 添加三大法人动向(台股筹码过滤器)— tw-only仅当 institution 区块 status='ok'
# 且有净额时注入,其他市场 status='not_supported' 会跳过,严格 additive。
institution_block = (
fundamental_context.get("institution", {})
if isinstance(fundamental_context, dict)
else {}
)
institution_data = (
institution_block.get("data", {})
if isinstance(institution_block, dict)
else {}
)
if (
isinstance(institution_block, dict)
and institution_block.get("status") == "ok"
and isinstance(institution_data, dict)
and all(
institution_data.get(key) is not None
for key in ("foreign_net", "trust_net", "dealer_net", "total_net")
)
):
prompt += f"""
### 三大法人动向(台股筹码过滤器,净买卖超,单位:股)
| 法人 | 净买卖超 | 决策含义 |
|------|------|----------|
| 外资 | {institution_data.get('foreign_net', 'N/A')} | 正值=净买超偏支持,负值=净卖超偏压制 |
| 投信 | {institution_data.get('trust_net', 'N/A')} | 投信持续买超常伴随中线做多 |
| 自营商 | {institution_data.get('dealer_net', 'N/A')} | 短线避险/自营方向参考 |
| 三大法人合计 | {institution_data.get('total_net', 'N/A')} | 台股最受关注的筹码信号 |
| 资料日期 | {institution_data.get('date', 'N/A')} | 来源 {institution_data.get('source', 'N/A')} |
> 三大法人是台股的筹码过滤器(相当于 A 股主力资金/龙虎榜的角色,但口径不同、不可混用):外资与投信同向净买支持价格、同向净卖压制价格。请据此判断台股筹码结构,不要在有本数据时写“筹码结构:数据缺失”。
"""
# 添加筹码分布数据
if 'chip' in context:
chip = context['chip']
profit_ratio = chip.get('profit_ratio', 0)
prompt += f"""
### 筹码分布数据(效率指标)
| 指标 | 数值 | 健康标准 |
|------|------|----------|
| **获利比例** | **{profit_ratio:.1%}** | 70-90%时警惕 |
| 平均成本 | {chip.get('avg_cost', 'N/A')} 元 | 现价应高于5-15% |
| 90%筹码集中度 | {chip.get('concentration_90', 0):.2%} | <15%为集中 |
| 70%筹码集中度 | {chip.get('concentration_70', 0):.2%} | |
| 筹码状态 | {chip.get('chip_status', unknown_text)} | |
"""
else:
chip_unavailable_text = get_chip_unavailable_text(report_language)
chip_instruction = (
"Do not fabricate profit ratio, average cost, or concentration. Mention chip data "
"unavailability only once in the report; do not repeat per-field no-data text in `chip_structure`."
if report_language in ("en", "ko")
else "请勿编造获利比例、平均成本或集中度;报告中只说明一次筹码数据不可用,不要把“数据缺失,无法判断”逐字段重复写入 `chip_structure`。"
)
prompt += f"""
### 筹码分布数据(效率指标)
> {chip_unavailable_text}
> {chip_instruction}
"""
# 添加趋势分析结果(仅隐式内建 bull_trend 默认回退保留旧口径)
if 'trend_analysis' in context:
trend = _sanitize_trend_analysis_for_prompt(
context['trend_analysis'],
volume_change_ratio=context.get('volume_change_ratio'),
)
consistency_notes = trend.get('prompt_consistency_notes', [])
if use_legacy_default_prompt:
bias_warning = "🚨 超过5%,严禁追高!" if trend.get('bias_ma5', 0) > 5 else "✅ 安全范围"
prompt += f"""
### 趋势分析预判(基于交易理念)
| 指标 | 数值 | 判定 |
|------|------|------|
| 趋势状态 | {trend.get('trend_status', unknown_text)} | |
| 均线排列 | {trend.get('ma_alignment', unknown_text)} | MA5>MA10>MA20为多头 |
| 趋势强度 | {trend.get('trend_strength', 0)}/100 | |
| **乖离率(MA5)** | **{trend.get('bias_ma5', 0):+.2f}%** | {bias_warning} |
| 乖离率(MA10) | {trend.get('bias_ma10', 0):+.2f}% | |
| 量能状态 | {trend.get('volume_status', unknown_text)} | {trend.get('volume_trend', '')} |
| 系统信号 | {trend.get('buy_signal', unknown_text)} | |
| 系统评分 | {trend.get('signal_score', 0)}/100 | |
#### 系统分析理由
**买入理由**
{chr(10).join('- ' + r for r in trend.get('signal_reasons', [''])) if trend.get('signal_reasons') else '- 无'}
**风险因素**
{chr(10).join('- ' + r for r in trend.get('risk_factors', [''])) if trend.get('risk_factors') else '- 无'}
"""
if consistency_notes:
prompt += f"""
**一致性约束**
{chr(10).join('- ' + note for note in consistency_notes)}
"""
else:
bias_warning = (
"🚨 偏离较大,需谨慎评估追高风险"
if trend.get('bias_ma5', 0) > 5
else "✅ 位置相对可控"
)
prompt += f"""
### 技术与结构分析(供激活技能判断参考)
| 指标 | 数值 | 说明 |
|------|------|------|
| 趋势状态 | {trend.get('trend_status', unknown_text)} | |
| 均线排列 | {trend.get('ma_alignment', unknown_text)} | 结合激活技能判断结构强弱 |
| 趋势强度 | {trend.get('trend_strength', 0)}/100 | |
| **价格位置(MA5)** | **{trend.get('bias_ma5', 0):+.2f}%** | {bias_warning} |
| 价格位置(MA10) | {trend.get('bias_ma10', 0):+.2f}% | |
| 量能状态 | {trend.get('volume_status', unknown_text)} | {trend.get('volume_trend', '')} |
| 系统信号 | {trend.get('buy_signal', unknown_text)} | |
| 系统评分 | {trend.get('signal_score', 0)}/100 | |
#### 系统分析理由
**支持因素**
{chr(10).join('- ' + r for r in trend.get('signal_reasons', [''])) if trend.get('signal_reasons') else '- 无'}
**风险因素**
{chr(10).join('- ' + r for r in trend.get('risk_factors', [''])) if trend.get('risk_factors') else '- 无'}
"""
if consistency_notes:
prompt += f"""
**一致性约束**
{chr(10).join('- ' + note for note in consistency_notes)}
"""
# 添加昨日对比数据
if 'yesterday' in context:
volume_change = context.get('volume_change_ratio', 'N/A')
prompt += f"""
### 量价变化
- 成交量较昨日变化:{volume_change}
- 价格较昨日变化:{context.get('price_change_ratio', 'N/A')}%
"""
parsed_volume_change = _safe_float(volume_change, default=math.nan)
if math.isfinite(parsed_volume_change) and parsed_volume_change > 10:
prompt += """
- ⚠️ 量能异常提示成交量较昨日放大超过10倍可能受异常数据或一次性冲量影响必须降权解读不能机械视为强确认信号
"""
# 添加新闻搜索结果(重点区域)
news_window_days: Optional[int] = None
context_window = context.get("news_window_days")
try:
if context_window is not None:
parsed_window = int(context_window)
if parsed_window > 0:
news_window_days = parsed_window
except (TypeError, ValueError):
news_window_days = None
if news_window_days is None:
prompt_config = self._get_runtime_config()
news_window_days = resolve_news_window_days(
news_max_age_days=getattr(prompt_config, "news_max_age_days", 3),
news_strategy_profile=getattr(prompt_config, "news_strategy_profile", "short"),
)
prompt += """
---
## 📰 舆情情报
"""
if news_context:
prompt += f"""
以下是 **{stock_name}({code})** 近{news_window_days}日的新闻搜索结果,请重点提取:
1. 🚨 **风险警报**:减持、处罚、利空
2. 🎯 **利好催化**:业绩、合同、政策
3. 📊 **业绩预期**:年报预告、业绩快报
4. 🕒 **时间规则(强制)**
- 输出到 `risk_alerts` / `positive_catalysts` / `latest_news` 的每一条都必须带具体日期YYYY-MM-DD
- 超出近{news_window_days}日窗口的新闻一律忽略
- 时间未知、无法确定发布日期的新闻一律忽略
```
{news_context}
```
"""
else:
prompt += """
未搜索到该股票近期的相关新闻。请主要依据技术面数据进行分析。
"""
# 注入缺失数据警告
if context.get('data_missing'):
prompt += """
⚠️ **数据缺失警告**
由于接口限制,当前无法获取完整的实时行情和技术指标数据。
请 **忽略上述表格中的 N/A 数据**,重点依据 **【📰 舆情情报】** 中的新闻进行基本面和情绪面分析。
在回答技术面问题(如均线、乖离率)时,请直接说明“数据缺失,无法判断”,**严禁编造数据**。
"""
# 明确的输出要求
prompt += f"""
---
## ✅ 分析任务
请为 **{stock_name}({code})** 生成【决策仪表盘】,严格按照 JSON 格式输出。
"""
if context.get('is_index_etf'):
prompt += """
> ⚠️ **指数/ETF 分析约束**:该标的为指数跟踪型 ETF 或市场指数。
> - 风险分析仅关注:**指数走势、跟踪误差、市场流动性**
> - 严禁将基金公司的诉讼、声誉、高管变动纳入风险警报
> - 业绩预期基于**指数成分股整体表现**,而非基金公司财报
> - `risk_alerts` 中不得出现基金管理人相关的公司经营风险
"""
prompt += f"""
### ⚠️ 重要:输出正确的股票名称格式
正确的股票名称格式为“股票名称股票代码例如“贵州茅台600519”。
如果上方显示的股票名称为"股票{code}"或不正确,请在分析开头**明确输出该股票的正确中文全称**。
"""
if use_legacy_default_prompt:
prompt += f"""
### 重点关注(必须明确回答):
1. ❓ 是否满足 MA5>MA10>MA20 多头排列?
2. ❓ 当前乖离率是否在安全范围内(<5%)?—— 超过5%必须标注"严禁追高"
3. ❓ 量能是否配合(缩量回调/放量突破)?
4. ❓ 筹码结构是否健康?
5. ❓ 消息面有无重大利空?(减持、处罚、业绩变脸等)
"""
else:
prompt += f"""
### 重点关注(必须明确回答):
1. ❓ 当前结构是否满足激活技能的关键触发条件?
2. ❓ 当前入场位置与风险回报是否合理?若偏离过大,请明确说明等待条件
3. ❓ 量能、波动与筹码结构是否支持当前结论?
4. ❓ 消息面有无重大利空或与技能结论冲突的信息?
5. ❓ 若结论成立,具体触发条件、止损位、观察点分别是什么?
"""
prompt += f"""
### 决策仪表盘要求:
- **股票名称**:必须输出正确的中文全称(如"贵州茅台"而非"股票600519"
- **核心结论**:一句话说清该买/该卖/该等
- **持仓分类建议**:空仓者怎么做 vs 持仓者怎么做
- **具体狙击点位**:买入价、止损价、目标价(精确到分)
- **检查清单**:每项用 ✅/⚠️/❌ 标记
- **消息面时间合规**`latest_news`、`risk_alerts`、`positive_catalysts` 不得包含超出近{news_window_days}日或时间未知的信息
- **技术面一致性**:严禁把“空头排列”和“多头排列”等互斥结论同时当作有效依据;若基本面/事件面与技术面冲突,必须明确写“事件先行、技术待确认”或“基本面偏多,但技术面尚未确认”
请输出完整的 JSON 格式决策仪表盘。"""
if report_language == "en":
prompt += """
### Output language requirements (highest priority)
- Keep every JSON key exactly as defined above; do not translate keys.
- `decision_type` must remain `buy`, `hold`, or `sell`.
- All human-readable JSON values must be in English.
- This includes `stock_name`, `trend_prediction`, `operation_advice`, `confidence_level`, all nested dashboard text, checklist items, and every summary field.
- Use the common English company name when you are confident. If not, keep the listed company name rather than inventing one.
- When data is missing, explain it in English instead of Chinese.
"""
elif report_language == "ko":
prompt += """
### Output language requirements (highest priority)
- Keep every JSON key exactly as defined above; do not translate keys.
- `decision_type` must remain `buy`, `hold`, or `sell`.
- All human-readable JSON values must be in Korean (한국어).
- This includes `stock_name`, `trend_prediction`, `operation_advice`, `confidence_level`, all nested dashboard text, checklist items, and every summary field.
- Use the common Korean or original listed company name when you are confident. If not, keep the listed company name rather than inventing one.
- When data is missing, explain it in Korean instead of Chinese.
"""
else:
prompt += f"""
### 输出语言要求(最高优先级)
- 所有 JSON 键名必须保持不变,不要翻译键名。
- `decision_type` 必须保持为 `buy`、`hold`、`sell`。
- 所有面向用户的人类可读文本值必须使用中文。
- 当数据缺失时,请使用中文直接说明“{no_data_text},无法判断”。
"""
return prompt
def _format_volume(self, volume: Optional[float]) -> str:
"""格式化成交量显示"""
if volume is None:
return 'N/A'
if volume >= 1e8:
return f"{volume / 1e8:.2f} 亿股"
elif volume >= 1e4:
return f"{volume / 1e4:.2f} 万股"
else:
return f"{volume:.0f}"
def _format_amount(self, amount: Optional[float]) -> str:
"""格式化成交额显示"""
if amount is None:
return 'N/A'
if amount >= 1e8:
return f"{amount / 1e8:.2f} 亿元"
elif amount >= 1e4:
return f"{amount / 1e4:.2f} 万元"
else:
return f"{amount:.0f}"
def _format_percent(self, value: Optional[float]) -> str:
"""格式化百分比显示"""
if value is None:
return 'N/A'
try:
return f"{float(value):.2f}%"
except (TypeError, ValueError):
return 'N/A'
def _format_price(self, value: Optional[float]) -> str:
"""格式化价格显示"""
if value is None:
return 'N/A'
try:
return f"{float(value):.2f}"
except (TypeError, ValueError):
return 'N/A'
def _build_market_snapshot(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""构建当日行情快照(展示用)"""
today = context.get('today', {}) or {}
realtime = context.get('realtime', {}) or {}
yesterday = context.get('yesterday', {}) or {}
prev_close = yesterday.get('close')
close = today.get('close')
high = today.get('high')
low = today.get('low')
amplitude = None
change_amount = None
if prev_close not in (None, 0) and high is not None and low is not None:
try:
amplitude = (float(high) - float(low)) / float(prev_close) * 100
except (TypeError, ValueError, ZeroDivisionError):
amplitude = None
if prev_close is not None and close is not None:
try:
change_amount = float(close) - float(prev_close)
except (TypeError, ValueError):
change_amount = None
snapshot = {
"date": context.get('date', '未知'),
"close": self._format_price(close),
"open": self._format_price(today.get('open')),
"high": self._format_price(high),
"low": self._format_price(low),
"prev_close": self._format_price(prev_close),
"pct_chg": self._format_percent(today.get('pct_chg')),
"change_amount": self._format_price(change_amount),
"amplitude": self._format_percent(amplitude),
"volume": self._format_volume(today.get('volume')),
"amount": self._format_amount(today.get('amount')),
}
if realtime:
snapshot.update({
"price": self._format_price(realtime.get('price')),
"volume_ratio": realtime.get('volume_ratio', 'N/A'),
"turnover_rate": self._format_percent(realtime.get('turnover_rate')),
"source": getattr(realtime.get('source'), 'value', realtime.get('source', 'N/A')),
})
return snapshot
def _check_content_integrity(
self,
result: AnalysisResult,
*,
require_phase_decision: bool = False,
) -> Tuple[bool, List[str]]:
"""Delegate to module-level check_content_integrity."""
return check_content_integrity(result, require_phase_decision=require_phase_decision)
def _build_integrity_complement_prompt(self, missing_fields: List[str], report_language: str = "zh") -> str:
"""Build complement instruction for missing mandatory fields."""
report_language = normalize_report_language(report_language)
if report_language in ("en", "ko"):
lines = ["### Completion requirements: fill the missing mandatory fields below and output the full JSON again:"]
for f in missing_fields:
if f == "sentiment_score":
lines.append("- sentiment_score: integer score from 0 to 100")
elif f == "operation_advice":
lines.append("- operation_advice: localized action advice")
elif f == "analysis_summary":
lines.append("- analysis_summary: concise analysis summary")
elif f == "dashboard.core_conclusion.one_sentence":
lines.append("- dashboard.core_conclusion.one_sentence: one-line decision")
elif f == "dashboard.intelligence.risk_alerts":
lines.append("- dashboard.intelligence.risk_alerts: risk alert list (can be empty)")
elif f == "dashboard.battle_plan.sniper_points.stop_loss":
lines.append("- dashboard.battle_plan.sniper_points.stop_loss: stop-loss level")
elif f == "dashboard.phase_decision.phase_context":
lines.append("- dashboard.phase_decision.phase_context: public market phase summary subset")
elif f == "dashboard.phase_decision.action_window":
lines.append("- dashboard.phase_decision.action_window: phase-aware action window")
elif f == "dashboard.phase_decision.immediate_action":
lines.append("- dashboard.phase_decision.immediate_action: act now / wait / watch / no intraday action")
elif f == "dashboard.phase_decision.watch_conditions":
lines.append("- dashboard.phase_decision.watch_conditions: list of watch conditions")
elif f == "dashboard.phase_decision.next_check_time":
lines.append("- dashboard.phase_decision.next_check_time: next check point or market-local time")
elif f == "dashboard.phase_decision.confidence_reason":
lines.append("- dashboard.phase_decision.confidence_reason: confidence rationale and data limits")
elif f == "dashboard.phase_decision.data_limitations":
lines.append("- dashboard.phase_decision.data_limitations: list of phase/data quality limitations")
return "\n".join(lines)
lines = ["### 补全要求:请在上方分析基础上补充以下必填内容,并输出完整 JSON"]
for f in missing_fields:
if f == "sentiment_score":
lines.append("- sentiment_score: 0-100 综合评分")
elif f == "operation_advice":
lines.append("- operation_advice: 买入/加仓/持有/减仓/卖出/观望")
elif f == "analysis_summary":
lines.append("- analysis_summary: 综合分析摘要")
elif f == "dashboard.core_conclusion.one_sentence":
lines.append("- dashboard.core_conclusion.one_sentence: 一句话决策")
elif f == "dashboard.intelligence.risk_alerts":
lines.append("- dashboard.intelligence.risk_alerts: 风险警报列表(可为空数组)")
elif f == "dashboard.battle_plan.sniper_points.stop_loss":
lines.append("- dashboard.battle_plan.sniper_points.stop_loss: 止损价")
elif f == "dashboard.phase_decision.phase_context":
lines.append("- dashboard.phase_decision.phase_context: 公开低敏市场阶段摘要子集")
elif f == "dashboard.phase_decision.action_window":
lines.append("- dashboard.phase_decision.action_window: 阶段化行动窗口")
elif f == "dashboard.phase_decision.immediate_action":
lines.append("- dashboard.phase_decision.immediate_action: 立即行动/等待确认/观察/无盘中动作")
elif f == "dashboard.phase_decision.watch_conditions":
lines.append("- dashboard.phase_decision.watch_conditions: 观察条件数组")
elif f == "dashboard.phase_decision.next_check_time":
lines.append("- dashboard.phase_decision.next_check_time: 下一次检查点或市场本地时间")
elif f == "dashboard.phase_decision.confidence_reason":
lines.append("- dashboard.phase_decision.confidence_reason: 置信度理由与数据限制")
elif f == "dashboard.phase_decision.data_limitations":
lines.append("- dashboard.phase_decision.data_limitations: 阶段/数据质量限制数组")
return "\n".join(lines)
def _build_integrity_retry_prompt(
self,
base_prompt: str,
previous_response: str,
missing_fields: List[str],
report_language: str = "zh",
) -> str:
"""Build retry prompt using the previous response as the complement baseline."""
complement = self._build_integrity_complement_prompt(missing_fields, report_language=report_language)
previous_output = previous_response.strip()
if normalize_report_language(report_language) in ("en", "ko"):
prefix = "### The previous output is below. Complete the missing fields based on that output and return the full JSON again. Do not omit existing fields:"
else:
prefix = "### 上一次输出如下,请在该输出基础上补齐缺失字段,并重新输出完整 JSON。不要省略已有字段"
return "\n\n".join([
base_prompt,
prefix,
previous_output,
complement,
])
def _apply_placeholder_fill(self, result: AnalysisResult, missing_fields: List[str]) -> None:
"""Delegate to module-level apply_placeholder_fill."""
apply_placeholder_fill(result, missing_fields)
def _extract_analysis_json_object(self, response_text: str) -> Tuple[str, Dict[str, Any]]:
"""Extract the single allowed JSON object from an LLM response."""
text = response_text or ""
stripped = text.strip()
if not stripped:
raise ValueError("empty_response")
fence_pattern = re.compile(
r"```[ \t]*(?P<lang>[A-Za-z0-9_-]*)[ \t]*\n?(?P<body>.*?)```",
flags=re.DOTALL,
)
fenced_matches = list(fence_pattern.finditer(text))
if len(fenced_matches) > 1:
raise ValueError("ambiguous_json")
if len(fenced_matches) == 1:
match = fenced_matches[0]
outside = (text[:match.start()] + text[match.end():]).strip()
if outside:
raise ValueError("ambiguous_json")
fence_lang = (match.group("lang") or "").strip().lower()
if fence_lang not in {"", "json"}:
raise ValueError("ambiguous_json")
json_str = match.group("body").strip()
data = self._load_analysis_json_candidate(json_str)
return json_str, data
if "```" in text:
raise ValueError("ambiguous_json")
try:
data = self._load_analysis_json_candidate(stripped)
except json.JSONDecodeError as exc:
if self._contains_embedded_json_object(text):
raise ValueError("ambiguous_json") from exc
raise
return stripped, data
def _load_analysis_json_candidate(self, json_str: str) -> Dict[str, Any]:
"""Parse one already-selected JSON candidate, repairing common LLM JSON drift."""
try:
data = json.loads(json_str)
except json.JSONDecodeError:
stripped = (json_str or "").strip()
try:
_obj, end = json.JSONDecoder().raw_decode(stripped)
except json.JSONDecodeError:
pass
else:
if stripped[end:].strip():
raise
if not (stripped.startswith("{") and stripped.endswith("}")):
raise
repaired = self._fix_json_string(stripped)
data = json.loads(repaired)
if not isinstance(data, dict):
raise TypeError("json_root_not_object")
return data
@staticmethod
def _contains_embedded_json_object(text: str) -> bool:
decoder = json.JSONDecoder()
count = 0
for index, char in enumerate(text):
if char != "{":
continue
try:
_obj, end = decoder.raw_decode(text[index:])
except json.JSONDecodeError:
continue
count += 1
before = text[:index].strip()
after = text[index + end:].strip()
if count > 1 or before or after:
return True
return False
def _validate_analysis_minimal_contract(self, data: Dict[str, Any]) -> None:
try:
AnalysisReportSchema.model_validate(data)
except Exception as exc:
logger.warning(
"AnalysisReportSchema validation failed; continuing with raw parser contract: %s",
str(exc)[:200],
)
minimal_keys = {
"sentiment_score",
"trend_prediction",
"operation_advice",
"analysis_summary",
"dashboard",
}
if not any(key in data for key in minimal_keys):
raise self._generation_validation_error(
GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
reason="minimal_contract_failed",
message="analysis JSON does not contain any minimal parser field",
)
if "sentiment_score" in data:
try:
int(data.get("sentiment_score", 50))
except (TypeError, ValueError) as exc:
raise self._generation_validation_error(
GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
reason="parser_contract_failed",
message="sentiment_score must be integer-compatible",
) from exc
def _generation_validation_error(
self,
error_code: GenerationErrorCode,
*,
reason: str,
message: str,
) -> GenerationError:
try:
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
except GenerationError:
backend_id = "generation_backend"
return GenerationError(
error_code=error_code,
stage="validation",
retryable=True,
fallbackable=True,
backend=backend_id,
provider=backend_id,
details={
"reason": reason,
"message": message,
},
)
def _parse_response(
self,
response_text: str,
code: str,
name: str
) -> AnalysisResult:
"""
解析 Gemini 响应(决策仪表盘版)
尝试从响应中提取 JSON 格式的分析结果,包含 dashboard 字段
如果解析失败,尝试智能提取或返回默认结果
"""
try:
report_language = normalize_report_language(
getattr(self._get_runtime_config(), "report_language", "zh")
)
try:
_json_str, data = self._extract_analysis_json_object(response_text)
self._validate_analysis_minimal_contract(data)
except Exception as exc:
logger.warning("无法从响应中提取唯一有效 JSON标记为解析失败: %s", exc)
return self._parse_text_response(response_text, code, name)
# 提取 dashboard 数据
dashboard = data.get('dashboard', None)
guardrail_reason = data.get("guardrail_reason") or data.get("downgrade_reason")
if guardrail_reason and isinstance(dashboard, dict):
score_calibration = dashboard.get("decision_score_calibration")
if not isinstance(score_calibration, dict):
score_calibration = {}
dashboard["decision_score_calibration"] = score_calibration
score_calibration.setdefault("guardrail_reason", str(guardrail_reason).strip())
# 归一化 signal_attributionLLM 可能返回字符串/负数/总和≠100
normalize_report_signal_attribution(dashboard)
# 优先使用 AI 返回的股票名称(如果原名称无效或包含代码)
ai_stock_name = data.get('stock_name')
if ai_stock_name and (name.startswith('股票') or name == code or 'Unknown' in name):
name = ai_stock_name
# 解析所有字段,使用默认值防止缺失
# 解析 decision_type如果没有则根据 operation_advice 推断
decision_type = data.get('decision_type', '')
if not decision_type:
op = data.get('operation_advice', localize_operation_advice('持有', report_language))
decision_type = infer_decision_type_from_advice(op, default='hold')
explicit_action = data.get("action")
if explicit_action is None and isinstance(dashboard, dict):
explicit_action = dashboard.get("action")
result = AnalysisResult(
code=code,
name=name,
# 核心指标
sentiment_score=int(data.get('sentiment_score', 50)),
trend_prediction=data.get('trend_prediction', localize_trend_prediction('震荡', report_language)),
operation_advice=data.get('operation_advice', localize_operation_advice('持有', report_language)),
decision_type=decision_type,
confidence_level=localize_confidence_level(
data.get('confidence_level', localize_confidence_level('', report_language)),
report_language,
),
report_language=report_language,
# 决策仪表盘
dashboard=dashboard,
# 走势分析
trend_analysis=data.get('trend_analysis', ''),
short_term_outlook=data.get('short_term_outlook', ''),
medium_term_outlook=data.get('medium_term_outlook', ''),
# 技术面
technical_analysis=data.get('technical_analysis', ''),
ma_analysis=data.get('ma_analysis', ''),
volume_analysis=data.get('volume_analysis', ''),
pattern_analysis=data.get('pattern_analysis', ''),
# 基本面
fundamental_analysis=data.get('fundamental_analysis', ''),
sector_position=data.get('sector_position', ''),
company_highlights=data.get('company_highlights', ''),
# 情绪面/消息面
news_summary=data.get('news_summary', ''),
market_sentiment=data.get('market_sentiment', ''),
hot_topics=data.get('hot_topics', ''),
# 综合
analysis_summary=data.get('analysis_summary', _localized_text(
report_language, en='Analysis completed', zh='分析完成', ko='분석 완료')),
key_points=data.get('key_points', ''),
risk_warning=data.get('risk_warning', ''),
buy_reason=data.get('buy_reason', ''),
# 元数据
search_performed=data.get('search_performed', False),
data_sources=data.get('data_sources', _localized_text(
report_language, en='Technical data', zh='技术面数据', ko='기술적 데이터')),
success=True,
)
return populate_decision_action_fields(
result,
explicit_action=explicit_action,
align_with_score=False,
)
except json.JSONDecodeError as e:
logger.warning(f"JSON 解析失败: {e},标记为解析失败")
return self._parse_text_response(response_text, code, name)
def _fix_json_string(self, json_str: str) -> str:
"""修复常见的 JSON 格式问题"""
import re
# 移除注释
json_str = re.sub(r'//.*?\n', '\n', json_str)
json_str = re.sub(r'/\*.*?\*/', '', json_str, flags=re.DOTALL)
# 修复尾随逗号
json_str = re.sub(r',\s*}', '}', json_str)
json_str = re.sub(r',\s*]', ']', json_str)
# 确保布尔值是小写
json_str = json_str.replace('True', 'true').replace('False', 'false')
# fix by json-repair
json_str = repair_json(json_str)
return json_str
def _validate_json_response(self, text: str) -> None:
"""Validate that *text* contains one parser-compatible JSON object.
Used as the ``response_validator`` argument to :meth:`_call_litellm` so
that a JSON-less or unparseable reply from the primary model is treated
as a model failure and triggers fallback to the next configured model.
Raises:
GenerationError: if the response has no unique parser-compatible
JSON object, the selected JSON candidate cannot be parsed, or
the parsed object cannot satisfy the minimal parser contract.
"""
try:
_json_str, data = self._extract_analysis_json_object(text)
except ValueError as exc:
reason = str(exc) or "invalid_json"
if reason == "ambiguous_json":
message = "JSON source is ambiguous"
else:
message = "No unique JSON object found in LLM response"
raise self._generation_validation_error(
GenerationErrorCode.INVALID_JSON,
reason=reason,
message=message,
) from exc
except json.JSONDecodeError as exc:
raise self._generation_validation_error(
GenerationErrorCode.INVALID_JSON,
reason="invalid_json",
message=str(exc)[:200],
) from exc
except Exception as exc:
raise self._generation_validation_error(
GenerationErrorCode.INVALID_JSON,
reason="invalid_json",
message=str(exc)[:200],
) from exc
self._validate_analysis_minimal_contract(data)
def _parse_text_response(
self,
response_text: str,
code: str,
name: str
) -> AnalysisResult:
"""从纯文本响应中尽可能提取分析信息"""
report_language = normalize_report_language(
getattr(self._get_runtime_config(), "report_language", "zh")
)
# 尝试识别关键词来判断情绪
sentiment_score = 50
trend = localize_trend_prediction('震荡', report_language)
advice = localize_operation_advice('持有', report_language)
text_lower = response_text.lower()
# 简单的情绪识别
positive_keywords = ['看多', '买入', '上涨', '突破', '强势', '利好', '加仓', 'bullish', 'buy']
negative_keywords = ['看空', '卖出', '下跌', '跌破', '弱势', '利空', '减仓', 'bearish', 'sell']
positive_count = sum(1 for kw in positive_keywords if kw in text_lower)
negative_count = sum(1 for kw in negative_keywords if kw in text_lower)
if positive_count > negative_count + 1:
sentiment_score = 65
trend = localize_trend_prediction('看多', report_language)
advice = localize_operation_advice('买入', report_language)
decision_type = 'buy'
elif negative_count > positive_count + 1:
sentiment_score = 35
trend = localize_trend_prediction('看空', report_language)
advice = localize_operation_advice('卖出', report_language)
decision_type = 'sell'
else:
decision_type = 'hold'
# 截取前500字符作为摘要
summary = response_text[:500] if response_text else _localized_text(
report_language, en='No analysis result', zh='无分析结果', ko='분석 결과 없음')
result = AnalysisResult(
code=code,
name=name,
sentiment_score=sentiment_score,
trend_prediction=trend,
operation_advice=advice,
decision_type=decision_type,
confidence_level=localize_confidence_level('', report_language),
analysis_summary=summary,
key_points=_localized_text(
report_language,
en='JSON parsing failed; treat this as best-effort output.',
zh='JSON解析失败仅供参考',
ko='JSON 파싱에 실패했습니다. 참고용으로만 사용하세요.',
),
risk_warning=_localized_text(
report_language,
en='The result may be inaccurate. Cross-check with other information.',
zh='分析结果可能不准确,建议结合其他信息判断',
ko='결과가 부정확할 수 있습니다. 다른 정보와 교차 확인하세요.',
),
raw_response=response_text,
success=False,
error_message='LLM response is not valid JSON; analysis result will not be persisted',
report_language=report_language,
)
return populate_decision_action_fields(result, align_with_score=False)
def batch_analyze(
self,
contexts: List[Dict[str, Any]],
delay_between: float = 2.0
) -> List[AnalysisResult]:
"""
批量分析多只股票
注意:为避免 API 速率限制,每次分析之间会有延迟
Args:
contexts: 上下文数据列表
delay_between: 每次分析之间的延迟(秒)
Returns:
AnalysisResult 列表
"""
results = []
for i, context in enumerate(contexts):
if i > 0:
logger.debug(f"等待 {delay_between} 秒后继续...")
time.sleep(delay_between)
result = self.analyze(context)
results.append(result)
return results
# 便捷函数
def get_analyzer() -> GeminiAnalyzer:
"""获取 LLM 分析器实例"""
return GeminiAnalyzer()
if __name__ == "__main__":
# 测试代码
logging.basicConfig(level=logging.DEBUG)
# 模拟上下文数据
test_context = {
'code': '600519',
'date': '2026-01-09',
'today': {
'open': 1800.0,
'high': 1850.0,
'low': 1780.0,
'close': 1820.0,
'volume': 10000000,
'amount': 18200000000,
'pct_chg': 1.5,
'ma5': 1810.0,
'ma10': 1800.0,
'ma20': 1790.0,
'volume_ratio': 1.2,
},
'ma_status': '多头排列 📈',
'volume_change_ratio': 1.3,
'price_change_ratio': 1.5,
}
analyzer = GeminiAnalyzer()
if analyzer.is_available():
print("=== AI 分析测试 ===")
result = analyzer.analyze(test_context)
print(f"分析结果: {result.to_dict()}")
else:
print("Gemini API 未配置,跳过测试")