From 3e98dfd9bc2e9700df4bce5ce8fa6649737e9a4b Mon Sep 17 00:00:00 2001 From: Alfred Date: Sun, 31 May 2026 23:10:30 +0800 Subject: [PATCH] feat: add analysis context data quality scoring (#1539) --- api/v1/schemas/history.py | 18 ++ .../report/AnalysisContextSummary.tsx | 102 ++++++++++- .../__tests__/AnalysisContextSummary.test.tsx | 35 ++++ apps/dsa-web/src/types/analysis.ts | 14 +- docs/CHANGELOG.md | 1 + docs/INDEX.md | 2 +- docs/INDEX_EN.md | 2 +- docs/analysis-context-pack.md | 50 ++++-- docs/full-guide.md | 10 +- docs/full-guide_EN.md | 10 +- src/analysis_context_pack_overview.py | 43 ++++- src/analysis_context_pack_prompt.py | 158 ++++++++++++++++++ src/schemas/analysis_context_pack.py | 7 +- src/services/analysis_context_builder.py | 107 +++++++++++- tests/test_agent_executor.py | 55 +++++- tests/test_analysis_api_contract.py | 30 ++++ tests/test_analysis_context_builder.py | 42 ++++- tests/test_analysis_context_pack_docs.py | 48 +++++- tests/test_analysis_context_pack_overview.py | 87 ++++++++++ tests/test_analysis_context_pack_prompt.py | 74 +++++++- tests/test_analysis_context_pack_schema.py | 44 +++-- tests/test_analysis_history.py | 18 ++ 22 files changed, 909 insertions(+), 48 deletions(-) diff --git a/api/v1/schemas/history.py b/api/v1/schemas/history.py index 2b8a38d14..cd915088d 100644 --- a/api/v1/schemas/history.py +++ b/api/v1/schemas/history.py @@ -182,6 +182,7 @@ class AnalysisContextPackOverviewBlock(BaseModel): "stale", "estimated", "partial", + "fetch_failed", ] = Field(..., description="数据块质量状态") source: Optional[str] = Field(None, description="数据来源") warnings: List[str] = Field(default_factory=list, description="数据块告警码") @@ -198,6 +199,7 @@ class AnalysisContextPackOverviewCounts(BaseModel): stale: int = 0 estimated: int = 0 partial: int = 0 + fetch_failed: int = 0 class AnalysisContextPackOverviewMetadata(BaseModel): @@ -207,6 +209,18 @@ class AnalysisContextPackOverviewMetadata(BaseModel): news_result_count: Optional[int] = Field(None, description="新闻结果数量") +class AnalysisContextPackOverviewDataQuality(BaseModel): + """AnalysisContextPack 可见摘要数据质量评分""" + + overall_score: Optional[int] = Field(None, ge=0, le=100, description="输入数据质量总分") + level: Optional[Literal["good", "usable", "limited", "poor"]] = Field( + None, + description="输入数据质量等级", + ) + block_scores: Dict[str, int] = Field(default_factory=dict, description="固定数据块质量分") + limitations: List[str] = Field(default_factory=list, description="低敏数据限制说明") + + class AnalysisContextPackOverview(BaseModel): """历史/API 可见的低敏 AnalysisContextPack 摘要""" @@ -215,6 +229,10 @@ class AnalysisContextPackOverview(BaseModel): subject: AnalysisContextPackOverviewSubject blocks: List[AnalysisContextPackOverviewBlock] = Field(default_factory=list) counts: AnalysisContextPackOverviewCounts + data_quality: Optional[AnalysisContextPackOverviewDataQuality] = Field( + None, + description="本次分析输入数据质量低敏摘要", + ) warnings: List[str] = Field(default_factory=list, description="顶层数据质量提醒") metadata: AnalysisContextPackOverviewMetadata = Field(default_factory=AnalysisContextPackOverviewMetadata) diff --git a/apps/dsa-web/src/components/report/AnalysisContextSummary.tsx b/apps/dsa-web/src/components/report/AnalysisContextSummary.tsx index d50be3e22..e5346dbba 100644 --- a/apps/dsa-web/src/components/report/AnalysisContextSummary.tsx +++ b/apps/dsa-web/src/components/report/AnalysisContextSummary.tsx @@ -25,6 +25,33 @@ const STATUS_STYLE: Record; + +const BLOCK_LABELS: Record> = { + zh: { + quote: '行情', + daily_bars: '日线', + technical: '技术', + news: '新闻', + fundamentals: '基本面', + chip: '筹码', + }, + en: { + quote: 'quote', + daily_bars: 'daily bars', + technical: 'technical', + news: 'news', + fundamentals: 'fundamentals', + chip: 'chip', + }, }; const TEXT = { @@ -35,8 +62,16 @@ const TEXT = { source: '来源', warnings: '告警', missingReasons: '缺失原因', + qualityScore: '质量分', + limitations: '数据限制', newsResultCount: '新闻结果数', triggerSource: '触发来源', + qualityLevel: { + good: '良好', + usable: '可用', + limited: '受限', + poor: '较差', + }, status: { available: '可用', missing: '缺失', @@ -45,6 +80,7 @@ const TEXT = { stale: '过期', estimated: '估算', partial: '部分可用', + fetch_failed: '抓取失败', }, }, en: { @@ -54,8 +90,16 @@ const TEXT = { source: 'Source', warnings: 'Warnings', missingReasons: 'Missing Reasons', + qualityScore: 'Quality', + limitations: 'Data Limitations', newsResultCount: 'News Results', triggerSource: 'Trigger', + qualityLevel: { + good: 'Good', + usable: 'Usable', + limited: 'Limited', + poor: 'Poor', + }, status: { available: 'Available', missing: 'Missing', @@ -64,6 +108,7 @@ const TEXT = { stale: 'Stale', estimated: 'Estimated', partial: 'Partial', + fetch_failed: 'Fetch failed', }, }, } as const; @@ -71,6 +116,7 @@ const TEXT = { const STATUS_ORDER: AnalysisContextPackBlockStatus[] = [ 'available', 'missing', + 'fetch_failed', 'not_supported', 'fallback', 'stale', @@ -85,9 +131,33 @@ const getCount = ( if (status === 'not_supported') { return overview.counts.notSupported || 0; } + if (status === 'fetch_failed') { + return overview.counts.fetchFailed || 0; + } return overview.counts[status] || 0; }; +const formatLimitation = ( + value: string, + language: ReportLanguage, + text: typeof TEXT.zh | typeof TEXT.en, +): string => { + const [rawKey, ...statusParts] = value.split(':'); + if (!rawKey || statusParts.length === 0) { + return value; + } + + const key = rawKey.trim(); + const status = statusParts.join(':').trim(); + if (!key || !status) { + return value; + } + + const label = BLOCK_LABELS[language][key] || key; + const statusLabel = (text.status as Record)[status] || status; + return language === 'zh' ? `${label}:${statusLabel}` : `${label}: ${statusLabel}`; +}; + export const AnalysisContextSummary: React.FC = ({ overview, language = 'zh', @@ -111,6 +181,11 @@ export const AnalysisContextSummary: React.FC = ({ : null, ].filter((item): item is string => Boolean(item)); const triggerSource = overview.metadata?.triggerSource?.trim(); + const quality = overview.dataQuality; + const qualityLevel = quality?.level || undefined; + const qualityStyle = qualityLevel ? QUALITY_STYLE[qualityLevel] : undefined; + const qualityLabel = qualityLevel ? text.qualityLevel[qualityLevel] : undefined; + const limitations = quality?.limitations?.map((item) => formatLimitation(item, reportLanguage, text)) || []; return ( @@ -128,6 +203,12 @@ export const AnalysisContextSummary: React.FC = ({ + {typeof quality?.overallScore === 'number' ? ( + + {qualityStyle ? : null} + {text.qualityScore} {quality.overallScore}/100{qualityLabel ? ` ${qualityLabel}` : ''} + + ) : null} {summaryCounts.map(({ status, value }) => { const style = STATUS_STYLE[status]; return ( @@ -155,8 +236,13 @@ export const AnalysisContextSummary: React.FC = ({ )} - actions={metadataItems.length > 0 ? ( + actions={metadataItems.length > 0 || typeof quality?.overallScore === 'number' ? (
+ {typeof quality?.overallScore === 'number' ? ( + + {text.qualityScore}: {quality.overallScore}/100{qualityLabel ? ` ${qualityLabel}` : ''} + + ) : null} {metadataItems.map((item) => ( {item} @@ -181,6 +267,13 @@ export const AnalysisContextSummary: React.FC = ({
) : null} + {limitations.length ? ( +
+ {text.limitations}: + {limitations.join(', ')} +
+ ) : null} + {overview.warnings?.length ? (
{text.warnings}: @@ -223,8 +316,13 @@ export const AnalysisContextSummary: React.FC = ({ })}
- {metadataItems.length > 0 ? ( + {metadataItems.length > 0 || typeof quality?.overallScore === 'number' ? (
+ {typeof quality?.overallScore === 'number' ? ( + + {text.qualityScore}: {quality.overallScore}/100{qualityLabel ? ` ${qualityLabel}` : ''} + + ) : null} {metadataItems.map((item) => ( {item} diff --git a/apps/dsa-web/src/components/report/__tests__/AnalysisContextSummary.test.tsx b/apps/dsa-web/src/components/report/__tests__/AnalysisContextSummary.test.tsx index f417022b0..9e0e37372 100644 --- a/apps/dsa-web/src/components/report/__tests__/AnalysisContextSummary.test.tsx +++ b/apps/dsa-web/src/components/report/__tests__/AnalysisContextSummary.test.tsx @@ -37,6 +37,14 @@ const overview: AnalysisContextPackOverview = { warnings: ['news_provider_timeout'], missingReasons: ['news_context_missing'], }, + { + key: 'fundamentals', + label: '基本面', + status: 'fetch_failed', + source: 'fundamental_pipeline', + warnings: [], + missingReasons: ['fundamental_pipeline_failed'], + }, ], counts: { available: 1, @@ -46,6 +54,20 @@ const overview: AnalysisContextPackOverview = { stale: 0, estimated: 0, partial: 0, + fetchFailed: 1, + }, + dataQuality: { + overallScore: 82, + level: 'usable', + blockScores: { + quote: 100, + daily_bars: 100, + technical: 100, + news: 35, + fundamentals: 25, + chip: 100, + }, + limitations: ['fundamentals: fetch_failed'], }, warnings: ['intraday_realtime_overlay'], metadata: { @@ -67,6 +89,8 @@ describe('AnalysisContextSummary', () => { expect(within(panel).getAllByText('输入数据块')[0]).toBeVisible(); expect(screen.getAllByText('可用 1')[0]).toBeVisible(); expect(screen.getAllByText('缺失 1')[0]).toBeVisible(); + expect(screen.getAllByText('抓取失败 1')[0]).toBeVisible(); + expect(screen.getAllByText('质量分 82/100 可用')[0]).toBeVisible(); expect(screen.getByText('触发来源: api')).toBeVisible(); expect(screen.getByText('来源: mock_quote')).not.toBeVisible(); @@ -77,8 +101,11 @@ describe('AnalysisContextSummary', () => { expect(screen.getByText('来源: mock_quote')).toBeVisible(); expect(screen.getByText('告警:')).toBeInTheDocument(); expect(screen.getByText(/intraday_realtime_overlay/)).toBeInTheDocument(); + expect(screen.getByText('数据限制:')).toBeInTheDocument(); + expect(screen.getByText(/基本面:抓取失败/)).toBeInTheDocument(); expect(screen.getByText(/news_provider_timeout/)).toBeInTheDocument(); expect(screen.getByText(/news_context_missing/)).toBeInTheDocument(); + expect(screen.getByText(/fundamental_pipeline_failed/)).toBeInTheDocument(); expect(screen.getAllByText('新闻结果数: 3').some((item) => item.textContent === '新闻结果数: 3')).toBe(true); }); @@ -90,7 +117,14 @@ describe('AnalysisContextSummary', () => { expect(screen.getAllByText('Input Blocks')[0]).toBeVisible(); expect(screen.getAllByText('Available 1')[0]).toBeVisible(); expect(screen.getAllByText('Missing 1')[0]).toBeVisible(); + expect(screen.getAllByText('Fetch failed 1')[0]).toBeVisible(); + expect(screen.getAllByText('Quality 82/100 Usable')[0]).toBeVisible(); expect(screen.getByText('Trigger: api')).toBeVisible(); + + fireEvent.click(within(panel).getAllByText('Input Blocks')[0]); + + expect(screen.getByText('Data Limitations:')).toBeInTheDocument(); + expect(screen.getByText(/fundamentals: Fetch failed/)).toBeInTheDocument(); }); it('surfaces degraded non-zero states in the collapsed summary', () => { @@ -122,6 +156,7 @@ describe('AnalysisContextSummary', () => { stale: 1, estimated: 0, partial: 0, + fetchFailed: 0, }, }; diff --git a/apps/dsa-web/src/types/analysis.ts b/apps/dsa-web/src/types/analysis.ts index f81ae894b..eb4dc66a7 100644 --- a/apps/dsa-web/src/types/analysis.ts +++ b/apps/dsa-web/src/types/analysis.ts @@ -130,7 +130,8 @@ export type AnalysisContextPackBlockStatus = | 'fallback' | 'stale' | 'estimated' - | 'partial'; + | 'partial' + | 'fetch_failed'; export interface AnalysisContextPackOverviewSubject { code: string; @@ -155,6 +156,7 @@ export interface AnalysisContextPackOverviewCounts { stale: number; estimated: number; partial: number; + fetchFailed: number; } export interface AnalysisContextPackOverviewMetadata { @@ -162,12 +164,22 @@ export interface AnalysisContextPackOverviewMetadata { newsResultCount?: number | null; } +export type AnalysisContextPackDataQualityLevel = 'good' | 'usable' | 'limited' | 'poor'; + +export interface AnalysisContextPackOverviewDataQuality { + overallScore?: number | null; + level?: AnalysisContextPackDataQualityLevel | null; + blockScores: Record; + limitations: string[]; +} + export interface AnalysisContextPackOverview { packVersion: string; createdAt?: string | null; subject: AnalysisContextPackOverviewSubject; blocks: AnalysisContextPackOverviewBlock[]; counts: AnalysisContextPackOverviewCounts; + dataQuality?: AnalysisContextPackOverviewDataQuality | null; warnings: string[]; metadata: AnalysisContextPackOverviewMetadata; } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ecb588dab..5cf47cf5b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [新功能] Web 报告页新增同股历史趋势抽屉入口,历史列表摘要补充趋势、摘要、模型和分析时行情字段,支持按当前股票查看历史分析并加载更多。 - [新功能] AnalysisContextPack P4 低敏 overview 接入历史详情、同步分析响应、completed 任务状态和 Web 报告页,展示数据块状态、来源、缺失原因与降级摘要。 +- [改进] AnalysisContextPack P5 增加数据质量评分、`fetch_failed` 状态、Prompt 数据限制区块和 Web 低敏质量展示。 - [文档] 明确同股历史趋势新增模型字段为历史快照展示元数据,不影响运行时 LLM Provider/Model/Base URL 路由与配置迁移清理;回退方式为按常规发布回滚本变更。 - [修复] 收口 Web 中文界面残留英文文案与设置页 help 缺口,回测页改为中文展示,并让 Web 设置页仅展示已注册且带说明的配置项。 diff --git a/docs/INDEX.md b/docs/INDEX.md index 1f52eb362..92c10ec0c 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -42,7 +42,7 @@ | [Bot 命令与接入](bot-command.md) | Bot 命令、Webhook、平台接入和回调说明 | | [Bot 平台配置](bot/) | 飞书、钉钉、Discord 等 Bot 配置截图和补充说明 | | [实时告警中心](alerts.md) | EventMonitor 基线、Web 规则管理、通知结果、冷却状态和 Phase 边界 | -| [分析上下文包契约、运行态消费与可见性](analysis-context-pack.md) | AnalysisContextPack 首版范围、字段质量状态、P1/P2 内部契约、P3 Prompt 摘要消费、P4 历史/API/Web 低敏可见性和源码锚点 | +| [分析上下文包契约、运行态消费与可见性](analysis-context-pack.md) | AnalysisContextPack 首版范围、字段质量状态、P1/P2 内部契约、P3 Prompt 摘要消费、P4 历史/API/Web 低敏可见性、P5 数据质量评分与源码锚点 | | [图片识别 Prompt](image-extract-prompt.md) | 图片识别股票信息的 Prompt 与使用边界 | | [OpenClaw Skill 集成](openclaw-skill-integration.md) | OpenClaw / Skill 外部集成说明 | diff --git a/docs/INDEX_EN.md b/docs/INDEX_EN.md index 258f297df..88b5ad80e 100644 --- a/docs/INDEX_EN.md +++ b/docs/INDEX_EN.md @@ -43,7 +43,7 @@ This is the entry point for project documentation. The README covers the project | [Bot Commands (EN)](bot-command_EN.md) | Bot commands, webhooks, platform integration, and callback behavior | | [Bot Platform Docs](bot/) ![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat) (Chinese-only) | Feishu, DingTalk, Discord, and related Bot configuration screenshots and notes | | [Real-Time Alert Center](alerts.md) ![P4 Badge](https://img.shields.io/badge/P4-yellow?style=flat) (Chinese-only) | EventMonitor baseline, Web rule management, notification attempts, cooldown state, and phase boundaries | -| [Analysis Context Pack Contract, Runtime Consumption, And Visibility](analysis-context-pack.md) ![P4 Badge](https://img.shields.io/badge/P4-orange?style=flat) (Chinese-only) | AnalysisContextPack first-scope boundaries, field quality states, P1/P2 internal contracts, P3 prompt-summary consumption, P4 history/API/Web low-sensitivity visibility, and source anchors | +| [Analysis Context Pack Contract, Runtime Consumption, And Visibility](analysis-context-pack.md) ![P5 Badge](https://img.shields.io/badge/P5-orange?style=flat) (Chinese-only) | AnalysisContextPack first-scope boundaries, field quality states, P1/P2 internal contracts, P3 prompt-summary consumption, P4 history/API/Web low-sensitivity visibility, P5 data-quality scoring, and source anchors | | [Image Extraction Prompt](image-extract-prompt.md) ![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat) (Chinese-only) | Prompt and boundaries for extracting stock information from images | | [OpenClaw Skill Integration](openclaw-skill-integration.md) ![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat) (Chinese-only) | OpenClaw / Skill external integration notes | diff --git a/docs/analysis-context-pack.md b/docs/analysis-context-pack.md index 43f9121b2..235960a32 100644 --- a/docs/analysis-context-pack.md +++ b/docs/analysis-context-pack.md @@ -1,6 +1,6 @@ -# AnalysisContextPack:P0 盘点、P1/P2 契约、P3 Runtime Consumption 与 P4 可见性 +# AnalysisContextPack:P0 盘点、P1/P2 契约、P3 Runtime Consumption、P4 可见性与 P5 数据质量 -本页是 Issue #1389 的专题文档,用于记录当前 DSA 分析上下文的真实来源、消费路径、字段状态边界,以及 `AnalysisContextPack` 内部契约、builder、运行态消费与低敏可见性边界。P0 负责现状盘点和契约边界;P1 只新增内部 schema/envelope、block catalog、类型约定和脱敏序列化;P2 只从 pipeline 已有 artifacts 组装 pack;P3 只把低敏摘要接入普通分析和 Agent 初始 Prompt;P4 只把低敏 overview 接入历史详情、同步分析响应、completed task status 和 Web 报告页。 +本页是 Issue #1389 的专题文档,用于记录当前 DSA 分析上下文的真实来源、消费路径、字段状态边界,以及 `AnalysisContextPack` 内部契约、builder、运行态消费、低敏可见性和数据质量评分边界。P0 负责现状盘点和契约边界;P1 只新增内部 schema/envelope、block catalog、类型约定和脱敏序列化;P2 只从 pipeline 已有 artifacts 组装 pack;P3 只把低敏摘要接入普通分析和 Agent 初始 Prompt;P4 只把低敏 overview 接入历史详情、同步分析响应、completed task status 和 Web 报告页;P5 在同一 `PACK_VERSION = "1.0"` 内补齐数据质量评分、`fetch_failed` 状态、Prompt 数据限制和 overview 低敏展示。 ## 术语与边界 @@ -23,7 +23,7 @@ P0 的目标是让后续 P1/P2/P3 可以基于真实仓库边界设计 `Analysis - P0 不新增 builder,不新增配置项,不新增数据库字段,不改变 API、报告、历史或通知 payload。 - P0 不接入 runtime,不改 `src/` 分析、Agent、告警、持仓、回测或通知逻辑。 - P0 不 pack 化 `market_review`、`market_light` 或大盘红绿灯专题快照;这些只作为历史快照中的其他 `report_kind` / 专题消费边界记录。 -- P0 不把 `fetch_failed` 加入字段质量状态词;`fetch_failed` 与 `not_supported` 的细分留到 P5 数据质量评分与模型提示阶段。 +- P0 当时不把 `fetch_failed` 加入字段质量状态词;P5 已在同一 1.0 umbrella 内追加该状态,用于明确区分“不支持”和“本次抓取失败”。 - P0 不在 README 扩写实现细节;本页作为专题文档,由 `docs/INDEX.md` / `docs/INDEX_EN.md` 入口发现。 ## P1 内部契约 @@ -33,11 +33,11 @@ P1 落地 `src/schemas/analysis_context_pack.py`,只定义内部 schema/envelo P1 schema 包含: - `PACK_VERSION = "1.0"`,并通过 `AnalysisContextPack.pack_version` 标记契约版本。 -- `ContextFieldStatus`:只允许 `available`、`missing`、`not_supported`、`fallback`、`stale`、`estimated`、`partial`;`fetch_failed` 仍留到 P5。 +- `ContextFieldStatus`:P1 首版只允许 `available`、`missing`、`not_supported`、`fallback`、`stale`、`estimated`、`partial`;P5 已追加 `fetch_failed`,表示字段或数据块本次抓取明确失败,不代表整次分析失败。 - `AnalysisSubject`:顶层身份槽,只包含 `code`、`stock_name`、`market`;`exchange`、`currency`、`industry` 留给后续扩展,P2 builder 不扩 P1 schema,也不重复新增 `identity` block。 - `AnalysisContextItem`:字段级输入项,包含 `status`、`value`、`source`、`timestamp`、`fallback_from`、`missing_reason`、`warnings`、`metadata`。 - `AnalysisContextBlock`:数据块级分组,包含 `status`、`items`、`source`、`timestamp`、`warnings`、`metadata`,其中 `items` 是 `Dict[str, AnalysisContextItem]`。 -- `DataQuality`:P1 只保留 `warnings` 与 `metadata` 容器,不做评分、聚合计数或模型置信度限制。 +- `DataQuality`:P1 只保留 `warnings` 与 `metadata` 容器;P5 已追加 `overall_score`、`level`、`block_scores`、`limitations`,仍保持低敏,不承载 raw payload。 - `AnalysisContextPack`:顶层 envelope,包含 `pack_version`、`subject`、`phase`、`blocks`、`data_quality`、`metadata`、`created_at`。 时间字段约定: @@ -88,24 +88,24 @@ P2 block 组装边界: - `enhanced_context.today` 上的 `is_partial_bar`、`is_estimated`、`estimated_fields` 优先进入 `technical`;缺失时仍兼容 `enhanced_context.today.data_source` 为 `realtime:*` 的旧 heuristic。partial/estimated 只进入 `technical`,`daily_bars` 不承载 partial/estimated,warning 使用 `intraday_realtime_overlay`。 - `technical` 优先复用 `trend_result.to_dict()`;无 trend artifact 时为 `missing`。 - `chip` 复用 `chip_data.to_dict()`;无 chip artifact 默认 `missing`,只有输入 metadata/artifact 明确 not_supported 时才标 `not_supported`。 -- `fundamentals` 只读 `fundamental_context` 参数;`ok` 映射为 `available`,`not_supported` 映射为 `not_supported`,`partial` 映射为 `partial`,`failed` 映射为 `missing` + 稳定 reason code;不写入 `errors[]` 原文。 +- `fundamentals` 只读 `fundamental_context` 参数;`ok` 映射为 `available`,`not_supported` 映射为 `not_supported`,`partial` 映射为 `partial`,P5 后 `failed` 映射为 `fetch_failed` + 稳定 reason code `fundamental_pipeline_failed`;不写入 `errors[]` 原文。 - `news` 非空白字符串为 `available`,空白或缺失为 `missing`;`news_result_count` 写入 pack metadata。 -P2 不组装 `portfolio`、`events`、`market_context`,也不把 `capital_flow` 拆成独立 block;首版只把它保留在 fundamentals 的 coverage/source chain metadata 中。P2 也不改变 Prompt、不让普通分析或 Agent runtime 消费 pack、不写入 history/task/report metadata、不暴露完整 pack 到 API/Web/Bot/Desktop/通知,不做 P5 data-quality scoring、`fetch_failed` 细分或模型置信度限制。 +P2 不组装 `portfolio`、`events`、`market_context`,也不把 `capital_flow` 拆成独立 block;首版只把它保留在 fundamentals 的 coverage/source chain metadata 中。P2 当时也不改变 Prompt、不让普通分析或 Agent runtime 消费 pack、不写入 history/task/report metadata、不暴露完整 pack 到 API/Web/Bot/Desktop/通知;P5 只在现有 builder 上追加低敏评分、`fetch_failed` 细分和 Prompt 限制,不新增 fetcher。 ## P3 Runtime Consumption P3 在 P2 `AnalysisContextBuilder` 之后接入运行态消费,但消费面限定为低敏 `analysis_context_pack_summary`。`StockAnalysisPipeline` 是 summary 的唯一生产者:在普通分析路径和 Agent 路径内完成 `PipelineAnalysisArtifacts` -> `AnalysisContextBuilder.build()` -> `format_analysis_context_pack_prompt_section()`,下游 analyzer、single-agent、multi-agent 只接收 summary 字符串,不自行构造完整 pack,也不读取 `AnalysisContextPack.to_safe_dict()` 的 block item 原始值。 -普通分析 Prompt 的顺序固定为:基础信息 -> #1386 `market_phase_context` 渲染区块 -> `analysis_context_pack_summary` -> 技术面、实时行情、新闻等既有区块。`analysis_context_pack_summary` 只包含 subject、`pack_version`、block `status` / `source` / `warnings` / `missing_reason`、`metadata.news_result_count` 和 `data_quality.warnings`,不得输出 `news.content`、`trend_result`、`chip`、`fundamental_context` 等原始 payload。 +普通分析 Prompt 的顺序固定为:基础信息 -> #1386 `market_phase_context` 渲染区块 -> `analysis_context_pack_summary` -> 技术面、实时行情、新闻等既有区块。`analysis_context_pack_summary` 只包含 subject、`pack_version`、block `status` / `source` / `warnings` / `missing_reason`、`metadata.news_result_count`、`data_quality.warnings` 和 P5 低敏数据限制,不得输出 `news.content`、`trend_result`、`chip`、`fundamental_context` 等原始 payload。 Agent 路径同样只传 summary。`AgentExecutor._build_user_message()` 在 market phase 段之后、pre-fetched JSON 之前插入 summary;`AgentOrchestrator._build_context()` 只把 summary 放入 `ctx.meta["analysis_context_pack_summary"]`,禁止写入 `ctx.data`;`BaseAgent._build_messages()` 在 market phase user message 之后、`_inject_cached_data()` 之前插入 summary。Agent 首轮没有复用普通分析新闻检索,`news` block 为 `missing` 是当前 P3 的预期状态。 -P3 当时不持久化完整 pack,不新增 API/Web/Bot/Desktop 字段,不改变报告 JSON schema,不把 summary 写入 `analysis_history.context_snapshot`、task status 或 report metadata;history snapshot 和 diagnostic snapshot 会剥离 `market_phase_context`、`analysis_context_pack`、`analysis_context_pack_summary` 等 runtime prompt key。P4 在此基础上新增低敏 overview,可见性只覆盖历史详情、同步分析响应、completed task status 和 Web 报告页;Agent 工具级 pack cache 复用、通知展示和数据质量评分仍留给后续阶段。 +P3 当时不持久化完整 pack,不新增 API/Web/Bot/Desktop 字段,不改变报告 JSON schema,不把 summary 写入 `analysis_history.context_snapshot`、task status 或 report metadata;history snapshot 和 diagnostic snapshot 会剥离 `market_phase_context`、`analysis_context_pack`、`analysis_context_pack_summary` 等 runtime prompt key。P4 在此基础上新增低敏 overview,可见性只覆盖历史详情、同步分析响应、completed task status 和 Web 报告页;P5 继续复用 summary 消费路径,不改 LLM 输出 JSON schema。Agent 工具级 pack cache 复用仍是后续工作。 ## P4 历史记录、任务状态与 Web 可见性 -P4 把 P3 已构建的 `AnalysisContextPack` 投影为公共低敏 `analysis_context_pack_overview`。该 overview 由专用 renderer 生成,公共 API 不允许直接返回 `AnalysisContextPack.to_safe_dict()` 或完整 pack dump。renderer 只输出白名单字段:`pack_version`、`created_at`、`subject.code` / `stock_name` / `market`、数据块 `key` / `label` / `status` / `source` / `warnings` / `missing_reasons`、按 block status 计数的 `counts`、顶层 `data_quality.warnings` 和 `metadata.trigger_source` / `metadata.news_result_count`。 +P4 把 P3 已构建的 `AnalysisContextPack` 投影为公共低敏 `analysis_context_pack_overview`。该 overview 由专用 renderer 生成,公共 API 不允许直接返回 `AnalysisContextPack.to_safe_dict()` 或完整 pack dump。renderer 只输出白名单字段:`pack_version`、`created_at`、`subject.code` / `stock_name` / `market`、数据块 `key` / `label` / `status` / `source` / `warnings` / `missing_reasons`、按 block status 计数的 `counts`、顶层 `data_quality.warnings` 和 `metadata.trigger_source` / `metadata.news_result_count`。P5 在同一 overview 上追加 `data_quality` 低敏对象,不重复顶层 `warnings`。 overview 不输出 `blocks.*.items`、`items.value`、`news.content`、`trend_result`、`chip`、`fundamental_context` 原始 payload,也不输出 `api_key`、`token`、`cookie`、`webhook_url`、`password`、`secret`、`authorization`、`sendkey`、`license_key` 等敏感键或值。 @@ -119,11 +119,32 @@ P4 持久化面只在 `analysis_history.context_snapshot` 顶层写入 `analysis API 返回给 Web 的 `details.context_snapshot` 会通过 `sanitize_context_snapshot_for_api()` 剥离顶层 `analysis_context_pack_overview`,避免 raw snapshot 面板重复展示或被当作完整上下文导出;overview 只从 `extract_analysis_context_pack_overview()` 单独取出。Agent 路径与普通分析路径写入同一 overview 形状,Agent 无新闻计数时 `metadata.news_result_count` 可为空。 -P4 Web 展示只在报告详情页渲染 `AnalysisContextSummary`,位置在策略点位和资讯之后、运行诊断之前;该区域默认折叠,折叠头部展示可用数、缺失数、非零的其他状态计数和触发来源,展开后展示数据块状态 badge、来源、warning、missing reason、状态计数和新闻结果数。无 overview 时不渲染占位。P4 不覆盖 pending/processing TaskPanel 或 SSE 进行中可见性,不改通知摘要、Bot/Desktop 专属展示、`market_review` overview、P5 数据质量评分或 `fetch_failed` 细分。 +P4 Web 展示只在报告详情页渲染 `AnalysisContextSummary`,位置在策略点位和资讯之后、运行诊断之前;该区域默认折叠,折叠头部展示可用数、缺失数、非零的其他状态计数和触发来源,展开后展示数据块状态 badge、来源、warning、missing reason、状态计数和新闻结果数。P5 后折叠头部还会展示质量分/等级,展开后展示 `limitations` 和 `fetch_failed` 状态。无 overview 时不渲染占位。P4/P5 不覆盖 pending/processing TaskPanel 或 SSE 进行中可见性,不改通知摘要、Bot/Desktop 专属展示或 `market_review` overview。 + +## P5 数据质量评分与 Prompt 数据限制 + +P5 在不升级 `PACK_VERSION`、不新增 fetcher、不新增配置项、不做历史迁移的前提下补齐三件事:内部低敏数据质量评分、跨模型通用的 Prompt 数据限制区块,以及既有 `analysis_context_pack_overview` 的低敏可见性扩展。P5 不改变 LLM 输出 JSON schema,不做后处理强制改写,也不纳入 #1386 的盘中动作字段。 + +状态契约新增 `fetch_failed`,用于“当前字段或数据块本次抓取明确失败”。首版只在已有 artifact 明确失败时使用,例如 `fundamental_context.status == "failed"`;空新闻、未配置搜索、无实时 quote artifact 或 chip 缺失仍保持既有 `missing` / `not_supported` 语义,避免把未启用能力误报成抓取失败。`fetch_failed` 不代表整次分析失败。 + +`DataQuality` 追加以下低敏字段,并保留旧 `warnings` / `metadata`: + +- `overall_score: Optional[int]`:0-100 总分。 +- `level: Optional["good"|"usable"|"limited"|"poor"]`:`>=85 good`、`>=70 usable`、`>=55 limited`,否则 `poor`。 +- `block_scores: Dict[str, int]`:固定六块的状态分。 +- `limitations: List[str]`:最多 5 条稳定限制说明,使用 `block: status` 形式。 + +评分只计算固定六块,不随辅助块缺失重归一化,未来新增 block 不自动影响总分。权重固定为 `quote=25`、`daily_bars=25`、`technical=25`、`news=10`、`fundamentals=10`、`chip=5`;状态分固定为 `available=100`、`partial=75`、`estimated=75`、`not_supported=70`、`fallback=65`、`stale=50`、`missing=35`、`fetch_failed=25`。总分公式为 `round(sum(block_score * weight) / 100)`。 + +`limitations` 优先列出核心块 `quote` / `daily_bars` / `technical` 的 `stale`、`fallback`、`missing`、`fetch_failed`、`partial`、`estimated`;其次列出辅助块 `news` / `fundamentals` / `chip` 的 `fetch_failed`、`fallback`、`stale`。辅助块单纯缺失不进入限制列表,避免把新闻缺失、未配置搜索或不支持能力解释成利好/利空。 + +Prompt 数据限制只在 `format_analysis_context_pack_prompt_section()` 内渲染,紧跟 pack summary,因此普通分析、single Agent 和 multi-agent 复用同一消费路径。中文输出 `数据限制`,英文输出 `Data Limitations`;只有真实 score 存在时才输出评分行。若 `quote`、`daily_bars` 或 `technical` 为 degraded 状态,Prompt 明确要求最终 JSON 的 `confidence_level` 不得为 `高` / `High`。Prompt 继续只使用 status/source/warnings/missing_reason/低敏评分,不输出 raw payload、新闻正文、趋势原始值、secret、token 或 webhook。 + +overview 只扩展现有公开面:`analysis_context_pack_overview.data_quality` 白名单包含 `overall_score`、`level`、`block_scores`、`limitations`,不重复公开 `warnings`。`render_analysis_context_pack_overview()` 与 `extract_analysis_context_pack_overview()` / persisted sanitizer 都会清洗该对象;旧 overview 缺少 `data_quality` 时仍正常读取。`details.context_snapshot` 继续剥离顶层 `analysis_context_pack_overview`,不公开完整 pack。 ## 字段质量状态 -未来 pack 的字段质量状态在 P0 只固定下列七词。它们描述字段或数据块的质量,不描述业务流程是否成功。 +未来 pack 的字段质量状态在 P0 先固定七词;P5 在同一 1.0 umbrella 内追加 `fetch_failed`。它们描述字段或数据块的质量,不描述业务流程是否成功。 | 状态 | 含义 | 示例边界 | | --- | --- | --- | @@ -134,6 +155,7 @@ P4 Web 展示只在报告详情页渲染 `AnalysisContextSummary`,位置在策 | `stale` | 字段存在,但时间新鲜度不足。 | 持仓估值中的 `price_stale` / `fx_stale`。 | | `estimated` | 字段是估算值,不应当作完整事实。 | 盘中用实时价补今日 bar 后生成技术估计。 | | `partial` | 数据块部分可用、部分缺失。 | 大盘红绿灯 `data_quality=partial` 或工具返回 `partial_cache`。 | +| `fetch_failed` | 当前路径确认尝试过抓取,但本次抓取失败。 | `fundamental_context.status == "failed"` 映射为基本面 block 抓取失败。 | ## 现有状态映射 @@ -150,7 +172,7 @@ P4 Web 展示只在报告详情页渲染 `AnalysisContextSummary`,位置在策 | `insufficient_data` / `completed` / `error` | 回测服务 | 不映射 | 这是回测执行状态;可在 pack 摘要中解释触发原因。 | | `sent` / `no_channel` / `partial_failed` / `all_failed` | 通知发送 | 不映射 | 这是通知投递结果,不能反推分析输入质量。 | | `data_quality=ok/partial/unavailable` | 大盘红绿灯 | `partial` 可映射,`unavailable` 视字段场景映射到 `missing` 或 `not_supported` | P0 不把大盘红绿灯纳入首版单股 pack。 | -| `fetch_failed` | 未来数据质量细分 | P0 不扩展 | P5 再区分 `not_supported` 与 `fetch_failed`。 | +| `fetch_failed` | 数据质量细分 | P5 映射为 `fetch_failed` | 只在已有 artifact 明确失败时使用,不代表整次分析失败。 | ## 七路径盘点 @@ -211,7 +233,7 @@ P0 只记录历史消费面。完整 pack 不应默认公开到历史详情或 ## 兼容与安全边界 - `analysis_history.context_snapshot.enhanced_context.date` 是当前回测日期解析兼容点,P1/P2 不能在没有迁移的情况下破坏。 -- 完整 pack 不默认公开到历史、API、Web 或通知;P4 只公开 `analysis_context_pack_overview` 低敏摘要、来源、fallback、stale、missing reason 和 block status count。 +- 完整 pack 不默认公开到历史、API、Web 或通知;P4/P5 只公开 `analysis_context_pack_overview` 低敏摘要、来源、fallback、stale、missing reason、block status count 和 `data_quality` 低敏评分。 - pack、日志、历史快照和 API 响应不得记录 API key、token、cookie、完整 webhook URL、邮箱密码、私有环境变量或其他密钥。 - `source`、`timestamp`、`fallback`、`stale`、`partial` 等质量元数据只用于解释输入限制,不用于阻断分析;除非现有核心路径本来就是 fail-fast。 - #1386 的盘前 / 盘中 phase 感知是后续 `phase` / `data_quality` 字段的重要背景;P0 只记录关系,不接入 runtime。 diff --git a/docs/full-guide.md b/docs/full-guide.md index 1f224b1c7..fa3ed69d5 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -780,7 +780,15 @@ P3 当时不新增 API/Web/Bot 参数,不写入 history/task status/report met P4 新增 `report.details.analysis_context_pack_overview`,历史详情、同步分析响应和 completed `/api/v1/analysis/status/{task_id}` 都会返回同一份低敏 overview;Web 端报告页在“策略点位”和“资讯”之后展示默认折叠的数据块摘要,折叠头部展示可用数、缺失数、非零的其他状态计数和触发来源,展开后展示数据块状态、来源、warning、missing reason、状态计数和新闻结果数。API 返回的 `details.context_snapshot` 会剥离顶层 `analysis_context_pack_overview`,避免透明度面板重复展示 raw snapshot。 -该 overview 不包含完整 pack、`analysis_context_pack_summary` Prompt 字符串、`items.value`、新闻正文、`trend_result`、筹码或基本面原始 payload。`SAVE_CONTEXT_SNAPSHOT=false` 或旧历史记录缺少 overview 时字段为空,报告仍正常返回。本阶段不覆盖 pending/processing TaskPanel、SSE 进行中事件、通知摘要、Bot/Desktop 专属展示、`market_review` overview 或 P5 数据质量评分。 +该 overview 不包含完整 pack、`analysis_context_pack_summary` Prompt 字符串、`items.value`、新闻正文、`trend_result`、筹码或基本面原始 payload。`SAVE_CONTEXT_SNAPSHOT=false` 或旧历史记录缺少 overview 时字段为空,报告仍正常返回。本阶段不覆盖 pending/processing TaskPanel、SSE 进行中事件、通知摘要、Bot/Desktop 专属展示、`market_review` overview 或数据质量评分。 + +#### AnalysisContextPack 数据质量评分与 Prompt 数据限制(Issue #1389 P5) + +P5 在不修改 `PACK_VERSION = "1.0"`、不新增数据源和不改变报告 JSON schema 的前提下,给 `AnalysisContextPack` 增加轻量数据质量评分与模型可读的数据限制区块。`ContextFieldStatus` 新增 `fetch_failed`,只表示字段或数据块本次抓取明确失败;首版仅把 `fundamental_context.status == "failed"` 映射为 `fetch_failed`,空新闻、未配置搜索、无实时 quote 或 chip 缺失仍按既有 `missing` / `not_supported` 处理。 + +`DataQuality` 现在包含 `overall_score`、`level`、`block_scores`、`limitations`,并保留旧 `warnings` / `metadata`。评分固定覆盖 `quote`、`daily_bars`、`technical`、`news`、`fundamentals`、`chip` 六块,不因辅助块缺失重归一化;核心块降级会在 Prompt 的“数据限制”区块中要求模型不要输出高置信度,辅助块缺失只限制对应分析段落,不应被解释为利好或利空。该 Prompt 区块由 `format_analysis_context_pack_prompt_section()` 统一生成,普通分析、single Agent 和 multi-agent 沿用同一低敏 summary,不暴露 raw payload、新闻正文、趋势原始值、secret、token 或 webhook。 + +历史详情、同步分析响应和 completed 任务状态继续只通过 `report.details.analysis_context_pack_overview` 暴露低敏字段;P5 只在该 overview 下新增 `data_quality`,包含 score、level、block_scores 和 limitations,不重复公开 `warnings`。Web 报告页仍默认折叠展示数据块摘要,折叠头部新增质量分/等级,展开后展示限制说明和 `fetch_failed` 状态;`details.context_snapshot` 继续剥离顶层 `analysis_context_pack_overview`。 #### 使用 Crontab diff --git a/docs/full-guide_EN.md b/docs/full-guide_EN.md index 3cf00de64..68cf1d8aa 100644 --- a/docs/full-guide_EN.md +++ b/docs/full-guide_EN.md @@ -657,7 +657,15 @@ P3 itself did not add API/Web/Bot parameters, persist fields into history/task s P4 adds `report.details.analysis_context_pack_overview`. History detail, sync analysis responses, and completed `/api/v1/analysis/status/{task_id}` responses now return the same low-sensitivity overview; the Web report page renders a collapsed data-block summary after Strategy and News, with available/missing counts, non-zero other status counts, and trigger source in the header and data-block status, source, warnings, missing reasons, status counts, and news result count after expansion. API `details.context_snapshot` strips the top-level `analysis_context_pack_overview` so the raw snapshot panel does not duplicate the public overview. -The overview does not include the full pack, the `analysis_context_pack_summary` prompt string, `items.value`, news body text, `trend_result`, chip, or fundamentals raw payloads. When `SAVE_CONTEXT_SNAPSHOT=false` or older history records lack the overview, the field is empty and the report still loads. This phase does not cover pending/processing TaskPanel, in-progress SSE events, notification summaries, Bot/Desktop-specific rendering, `market_review` overview, or P5 data-quality scoring. +The overview does not include the full pack, the `analysis_context_pack_summary` prompt string, `items.value`, news body text, `trend_result`, chip, or fundamentals raw payloads. When `SAVE_CONTEXT_SNAPSHOT=false` or older history records lack the overview, the field is empty and the report still loads. This phase does not cover pending/processing TaskPanel, in-progress SSE events, notification summaries, Bot/Desktop-specific rendering, `market_review` overview, or data-quality scoring. + +### AnalysisContextPack Data Quality Scoring and Prompt Limitations (Issue #1389 P5) + +P5 adds lightweight data-quality scoring and model-readable data limitations to `AnalysisContextPack` without changing `PACK_VERSION = "1.0"`, adding data sources, or changing the report JSON schema. `ContextFieldStatus` now includes `fetch_failed`, which only means a field or data block explicitly failed to fetch in this run; the first mapping only turns `fundamental_context.status == "failed"` into `fetch_failed`, while empty news, unconfigured search, missing realtime quote, or missing chip data keep the existing `missing` / `not_supported` semantics. + +`DataQuality` now contains `overall_score`, `level`, `block_scores`, and `limitations`, while preserving the old `warnings` / `metadata` fields. Scoring is fixed to six blocks: `quote`, `daily_bars`, `technical`, `news`, `fundamentals`, and `chip`; auxiliary missing blocks are not re-normalized away. When core blocks are degraded, the prompt's `Data Limitations` section tells the model not to return high confidence; missing auxiliary blocks only constrain their matching analysis sections and must not be interpreted as bullish or bearish. The section is generated by `format_analysis_context_pack_prompt_section()`, so regular analysis, single Agent, and multi-agent paths reuse the same low-sensitivity summary without exposing raw payloads, news body text, raw trend values, secrets, tokens, or webhooks. + +History detail, sync analysis responses, and completed task status responses still expose only `report.details.analysis_context_pack_overview`; P5 only adds a nested `data_quality` object with score, level, block_scores, and limitations, and does not duplicate `warnings`. The Web report page remains collapsed by default, adds quality score/level to the header, and shows limitations plus `fetch_failed` status after expansion; API `details.context_snapshot` continues to strip the top-level `analysis_context_pack_overview`. --- diff --git a/src/analysis_context_pack_overview.py b/src/analysis_context_pack_overview.py index efff0c323..9823364ba 100644 --- a/src/analysis_context_pack_overview.py +++ b/src/analysis_context_pack_overview.py @@ -20,6 +20,7 @@ from src.schemas.analysis_context_pack import ContextFieldStatus ANALYSIS_CONTEXT_PACK_OVERVIEW_KEY = "analysis_context_pack_overview" _ALL_STATUSES = tuple(status.value for status in ContextFieldStatus) +_DATA_QUALITY_BLOCK_KEYS = {"quote", "daily_bars", "technical", "news", "fundamentals", "chip"} logger = logging.getLogger(__name__) @@ -77,6 +78,7 @@ def render_analysis_context_pack_overview( }, "blocks": overview_blocks, "counts": counts, + "data_quality": _sanitize_data_quality(payload.get("data_quality")), "warnings": _list_strings(_nested(payload, "data_quality", "warnings")), "metadata": { "trigger_source": _safe_text(metadata.get("trigger_source")) or None, @@ -159,7 +161,7 @@ def _sanitize_persisted_overview(overview: Mapping[str, Any]) -> Optional[Dict[s return None metadata = overview.get("metadata") if isinstance(overview.get("metadata"), Mapping) else {} - return { + sanitized = { "pack_version": _safe_text(overview.get("pack_version")) or "1.0", "created_at": _safe_text(overview.get("created_at")) or None, "subject": { @@ -175,6 +177,20 @@ def _sanitize_persisted_overview(overview: Mapping[str, Any]) -> Optional[Dict[s "news_result_count": _safe_int(metadata.get("news_result_count")), }, } + if "data_quality" in overview: + sanitized["data_quality"] = _sanitize_data_quality(overview.get("data_quality")) + return sanitized + + +def _sanitize_data_quality(value: Any) -> Optional[Dict[str, Any]]: + if not isinstance(value, Mapping): + return None + return { + "overall_score": _safe_score(value.get("overall_score")), + "level": _safe_quality_level(value.get("level")), + "block_scores": _safe_block_scores(value.get("block_scores")), + "limitations": _list_strings(value.get("limitations"), limit=5), + } def _safe_status(value: Any) -> Optional[str]: @@ -182,6 +198,31 @@ def _safe_status(value: Any) -> Optional[str]: return text if text in _ALL_STATUSES else None +def _safe_quality_level(value: Any) -> Optional[str]: + text = _safe_text(value) + return text if text in {"good", "usable", "limited", "poor"} else None + + +def _safe_score(value: Any) -> Optional[int]: + if isinstance(value, bool) or not isinstance(value, int): + return None + if 0 <= value <= 100: + return value + return None + + +def _safe_block_scores(value: Any) -> Dict[str, int]: + if not isinstance(value, Mapping): + return {} + result: Dict[str, int] = {} + for key, score in value.items(): + text_key = _safe_text(key) + safe_score = _safe_score(score) + if text_key in _DATA_QUALITY_BLOCK_KEYS and safe_score is not None: + result[text_key] = safe_score + return result + + def _safe_text(value: Any) -> str: if value is None: return "" diff --git a/src/analysis_context_pack_prompt.py b/src/analysis_context_pack_prompt.py index 48a53e169..83664d98c 100644 --- a/src/analysis_context_pack_prompt.py +++ b/src/analysis_context_pack_prompt.py @@ -25,6 +25,51 @@ BLOCK_LABELS_EN = { "news": "news", } +STATUS_LABELS_ZH = { + "available": "可用", + "missing": "缺失", + "not_supported": "不支持", + "fallback": "降级", + "stale": "过期", + "estimated": "估算", + "partial": "部分可用", + "fetch_failed": "抓取失败", +} + +STATUS_LABELS_EN = { + "available": "available", + "missing": "missing", + "not_supported": "not supported", + "fallback": "fallback", + "stale": "stale", + "estimated": "estimated", + "partial": "partial", + "fetch_failed": "fetch failed", +} + +QUALITY_LEVEL_LABELS_ZH = { + "good": "良好", + "usable": "可用", + "limited": "受限", + "poor": "较差", +} + +QUALITY_LEVEL_LABELS_EN = { + "good": "good", + "usable": "usable", + "limited": "limited", + "poor": "poor", +} + +CORE_DEGRADED_STATUSES = { + "stale", + "fallback", + "missing", + "fetch_failed", + "partial", + "estimated", +} + SENSITIVE_MARKERS = ( "api_key", "access_token", @@ -115,6 +160,7 @@ def _format_zh(payload: Dict[str, Any]) -> str: warnings = _list_strings(_nested(payload, "data_quality", "warnings")) if warnings: lines.append(f"- 数据质量提醒:{_join_text(warnings, lang='zh')}") + lines.extend(_data_limitation_lines(payload, lang="zh")) return "\n".join(lines) + "\n" @@ -131,6 +177,7 @@ def _format_en(payload: Dict[str, Any]) -> str: warnings = _list_strings(_nested(payload, "data_quality", "warnings")) if warnings: lines.append(f"- Data quality notes: {_join_text(warnings, lang='en')}") + lines.extend(_data_limitation_lines(payload, lang="en")) return "\n".join(lines) + "\n" @@ -221,6 +268,117 @@ def _metadata_lines(payload: Dict[str, Any], *, lang: str) -> List[str]: ] +def _data_limitation_lines(payload: Dict[str, Any], *, lang: str) -> List[str]: + lines = ["", "## Data Limitations" if lang == "en" else "## 数据限制"] + data_quality = payload.get("data_quality") + if not isinstance(data_quality, Mapping): + data_quality = {} + + score = _safe_score(data_quality.get("overall_score")) + level = _safe_text(data_quality.get("level")) + if score is not None: + level_text = _quality_level_label(level, lang=lang) + if lang == "en": + line = f"- Data quality score: {score}/100" + if level_text: + line += f" ({level_text})" + else: + line = f"- 数据质量评分:{score}/100" + if level_text: + line += f"({level_text})" + lines.append(line) + + limitations = _localized_limitations( + _list_strings(data_quality.get("limitations")), + lang=lang, + ) + if limitations: + label = "Known limitations" if lang == "en" else "已知限制" + separator = ": " if lang == "en" else ":" + lines.append(f"- {label}{separator}{_join_text(limitations, lang=lang)}") + + if _has_core_degraded_block(payload): + if lang == "en": + lines.append( + "- Confidence rule: when quote, daily bars, or technical data is " + "stale, fallback, missing, fetch_failed, partial, or estimated, " + "the final JSON confidence_level must not be High." + ) + else: + lines.append( + "- 置信度规则:当 quote、daily_bars 或 technical 为 stale、fallback、missing、" + "fetch_failed、partial 或 estimated 时,最终 JSON 的 confidence_level 不得为高。" + ) + + if lang == "en": + lines.append( + "- Analysis rule: missing auxiliary blocks only limit their matching " + "analysis sections; do not treat missing data itself as bullish or bearish." + ) + lines.append( + "- Safety rule: use only status, source, warnings, and missing_reason " + "from this summary; do not reproduce raw payloads, news body text, " + "raw trend values, secrets, tokens, or webhooks." + ) + else: + lines.append( + "- 分析规则:辅助数据块缺失只限制对应分析段落,不要把缺失本身解释为利好或利空。" + ) + lines.append( + "- 安全规则:只使用本摘要中的 status、source、warnings 和 missing_reason;" + "不要复述 raw payload、新闻正文、趋势原始值、secret、token 或 webhook。" + ) + return lines + + +def _localized_limitations(limitations: List[str], *, lang: str) -> List[str]: + labels = get_analysis_context_pack_block_labels(lang) + status_labels = STATUS_LABELS_EN if lang == "en" else STATUS_LABELS_ZH + result: List[str] = [] + for item in limitations: + key, separator, status = item.partition(":") + if not separator: + result.append(item) + continue + normalized_key = key.strip() + normalized_status = status.strip() + label = labels.get(normalized_key, _safe_text(normalized_key)) + status_label = status_labels.get(normalized_status, _safe_text(normalized_status)) + if not label or not status_label: + continue + result.append( + f"{label}: {status_label}" if lang == "en" else f"{label}:{status_label}" + ) + return result[:5] + + +def _has_core_degraded_block(payload: Dict[str, Any]) -> bool: + blocks = payload.get("blocks") + if not isinstance(blocks, Mapping): + return False + for key in ("quote", "daily_bars", "technical"): + block = blocks.get(key) + if not isinstance(block, Mapping): + continue + status = _safe_text(block.get("status")) + if status in CORE_DEGRADED_STATUSES: + return True + return False + + +def _quality_level_label(level: str, *, lang: str) -> str: + labels = QUALITY_LEVEL_LABELS_EN if lang == "en" else QUALITY_LEVEL_LABELS_ZH + return labels.get(level, "") + + +def _safe_score(value: Any) -> Optional[int]: + if isinstance(value, bool) or not isinstance(value, int): + return None + if 0 <= value <= 100: + return value + return None + + def _first_item_field(items: Any, field: str) -> Optional[str]: if not isinstance(items, Mapping): return None diff --git a/src/schemas/analysis_context_pack.py b/src/schemas/analysis_context_pack.py index c9c24e4f4..a1bf63cde 100644 --- a/src/schemas/analysis_context_pack.py +++ b/src/schemas/analysis_context_pack.py @@ -45,6 +45,7 @@ class ContextFieldStatus(str, Enum): STALE = "stale" ESTIMATED = "estimated" PARTIAL = "partial" + FETCH_FAILED = "fetch_failed" class AnalysisSubject(_AnalysisContextModel): @@ -90,8 +91,12 @@ class AnalysisContextBlock(_AnalysisContextModel): class DataQuality(_AnalysisContextModel): - """Container for future quality summaries without P5 scoring semantics.""" + """Low-sensitivity data quality summary for an AnalysisContextPack.""" + overall_score: Optional[int] = Field(None, ge=0, le=100) + level: Optional[Literal["good", "usable", "limited", "poor"]] = None + block_scores: Dict[str, int] = Field(default_factory=dict) + limitations: List[str] = Field(default_factory=list) warnings: List[str] = Field(default_factory=list) metadata: Dict[str, Any] = Field(default_factory=dict) diff --git a/src/services/analysis_context_builder.py b/src/services/analysis_context_builder.py index 43cf8d2b6..2cea68186 100644 --- a/src/services/analysis_context_builder.py +++ b/src/services/analysis_context_builder.py @@ -21,6 +21,37 @@ from src.schemas.analysis_context_pack import ( _REALTIME_OVERLAY_WARNING = "intraday_realtime_overlay" _REALTIME_FALLBACK_WARNING = "realtime_provider_fallback" _FUNDAMENTAL_FAILED_REASON = "fundamental_pipeline_failed" +_QUALITY_BLOCK_WEIGHTS: Dict[str, int] = { + "quote": 25, + "daily_bars": 25, + "technical": 25, + "news": 10, + "fundamentals": 10, + "chip": 5, +} +_STATUS_SCORES: Dict[ContextFieldStatus, int] = { + ContextFieldStatus.AVAILABLE: 100, + ContextFieldStatus.PARTIAL: 75, + ContextFieldStatus.ESTIMATED: 75, + ContextFieldStatus.NOT_SUPPORTED: 70, + ContextFieldStatus.FALLBACK: 65, + ContextFieldStatus.STALE: 50, + ContextFieldStatus.MISSING: 35, + ContextFieldStatus.FETCH_FAILED: 25, +} +_CORE_LIMITATION_STATUSES = { + ContextFieldStatus.STALE, + ContextFieldStatus.FALLBACK, + ContextFieldStatus.MISSING, + ContextFieldStatus.FETCH_FAILED, + ContextFieldStatus.PARTIAL, + ContextFieldStatus.ESTIMATED, +} +_AUX_LIMITATION_STATUSES = { + ContextFieldStatus.FETCH_FAILED, + ContextFieldStatus.FALLBACK, + ContextFieldStatus.STALE, +} @dataclass(frozen=True) @@ -62,6 +93,7 @@ class AnalysisContextBuilder: blocks["chip"] = _build_chip_block(artifacts) blocks["fundamentals"] = _build_fundamentals_block(artifacts) blocks["news"] = _build_news_block(artifacts) + data_quality = _build_data_quality(blocks, warnings=data_quality_warnings) return AnalysisContextPack( subject=AnalysisSubject( @@ -71,7 +103,7 @@ class AnalysisContextBuilder: ), phase=artifacts.phase, blocks=blocks, - data_quality=DataQuality(warnings=data_quality_warnings), + data_quality=data_quality, metadata=metadata, ) @@ -413,6 +445,70 @@ def _build_news_block(artifacts: PipelineAnalysisArtifacts) -> AnalysisContextBl ) +def _build_data_quality( + blocks: Dict[str, AnalysisContextBlock], + *, + warnings: List[str], +) -> DataQuality: + block_scores: Dict[str, int] = {} + weighted_sum = 0 + for key, weight in _QUALITY_BLOCK_WEIGHTS.items(): + status = _quality_block_status(blocks, key) + score = _STATUS_SCORES.get(status, _STATUS_SCORES[ContextFieldStatus.MISSING]) + block_scores[key] = score + weighted_sum += score * weight + + overall_score = int(round(weighted_sum / 100)) + return DataQuality( + overall_score=overall_score, + level=_quality_level(overall_score), + block_scores=block_scores, + limitations=_quality_limitations(blocks), + warnings=warnings, + ) + + +def _quality_block_status( + blocks: Dict[str, AnalysisContextBlock], + key: str, +) -> ContextFieldStatus: + block = blocks.get(key) + if block is None: + return ContextFieldStatus.MISSING + status = block.status + if isinstance(status, ContextFieldStatus): + return status + try: + return ContextFieldStatus(str(status)) + except ValueError: + return ContextFieldStatus.MISSING + + +def _quality_level(score: int) -> str: + if score >= 85: + return "good" + if score >= 70: + return "usable" + if score >= 55: + return "limited" + return "poor" + + +def _quality_limitations(blocks: Dict[str, AnalysisContextBlock]) -> List[str]: + limitations: List[str] = [] + for key in ("quote", "daily_bars", "technical"): + status = _quality_block_status(blocks, key) + if status in _CORE_LIMITATION_STATUSES: + limitations.append(f"{key}: {status.value}") + + for key in ("news", "fundamentals", "chip"): + status = _quality_block_status(blocks, key) + if status in _AUX_LIMITATION_STATUSES: + limitations.append(f"{key}: {status.value}") + + return limitations[:5] + + def _to_dict(value: Optional[Any]) -> Dict[str, Any]: if value is None: return {} @@ -589,6 +685,8 @@ def _fundamental_status(status: str) -> ContextFieldStatus: return ContextFieldStatus.NOT_SUPPORTED if status == "partial": return ContextFieldStatus.PARTIAL + if status == "failed": + return ContextFieldStatus.FETCH_FAILED return ContextFieldStatus.MISSING @@ -598,8 +696,11 @@ def _fundamental_payload_status( ) -> ContextFieldStatus: if has_payload: return block_status - if block_status == ContextFieldStatus.NOT_SUPPORTED: - return ContextFieldStatus.NOT_SUPPORTED + if block_status in { + ContextFieldStatus.NOT_SUPPORTED, + ContextFieldStatus.FETCH_FAILED, + }: + return block_status return ContextFieldStatus.MISSING diff --git a/tests/test_agent_executor.py b/tests/test_agent_executor.py index be591aa2a..3fb6e6c00 100644 --- a/tests/test_agent_executor.py +++ b/tests/test_agent_executor.py @@ -32,7 +32,12 @@ from src.agent.executor import AgentExecutor, AgentResult from src.agent.llm_adapter import LLMResponse, ToolCall from src.agent.runner import parse_dashboard_json, run_agent_loop, serialize_tool_result from src.agent.tools.registry import ToolRegistry, ToolDefinition, ToolParameter +from src.analysis_context_pack_prompt import format_analysis_context_pack_prompt_section from src.config import Config +from src.services.analysis_context_builder import ( + AnalysisContextBuilder, + PipelineAnalysisArtifacts, +) from src.storage import DatabaseManager @@ -61,6 +66,44 @@ def _make_mock_adapter(): return adapter +def _build_analysis_context_pack_summary( + *, + realtime_quote=None, + fundamental_context=None, +) -> str: + artifacts = PipelineAnalysisArtifacts( + code="600519", + stock_name="贵州茅台", + market="cn", + phase=None, + base_context={ + "today": {"close": 1880.0}, + "yesterday": {"close": 1870.0}, + "date": "2026-03-26", + }, + enhanced_context={}, + realtime_quote=realtime_quote + if realtime_quote is not None + else {"price": 1880.0, "source": "mock_quote"}, + trend_result={"trend_status": "available"}, + chip_data={"source": "mock_chip", "date": "2026-03-26"}, + fundamental_context=fundamental_context + if fundamental_context is not None + else { + "status": "ok", + "coverage": {"valuation": "ok"}, + "source_chain": [{"provider": "fundamental_pipeline"}], + }, + news_context="新闻摘要", + news_result_count=1, + metadata={"trigger_source": "api"}, + ) + return format_analysis_context_pack_prompt_section( + AnalysisContextBuilder.build(artifacts), + report_language="zh", + ) + + SAMPLE_DASHBOARD = { "stock_name": "贵州茅台", "sentiment_score": 75, @@ -920,6 +963,13 @@ class TestBuildUserMessage(unittest.TestCase): self.assertIn("报告类型: daily", msg) def test_message_renders_readable_market_phase_context_without_raw_keys(self): + summary = _build_analysis_context_pack_summary( + realtime_quote={ + "price": 1880.0, + "source": "fallback", + "fallback_from": "primary_realtime_provider", + }, + ) msg = self.executor._build_user_message( "Analyze", context={ @@ -932,13 +982,16 @@ class TestBuildUserMessage(unittest.TestCase): "effective_daily_bar_date": "2026-03-26", "is_partial_bar": True, }, - "analysis_context_pack_summary": "\n## 分析上下文包摘要\n- 数据块状态:行情 available\n", + "analysis_context_pack_summary": summary, "realtime_quote": {"price": 1880.0}, }, ) self.assertIn("股票代码: 600519", msg) self.assertIn("市场阶段上下文", msg) self.assertIn("分析上下文包摘要", msg) + self.assertIn("数据限制", msg) + self.assertIn("已知限制:行情:降级", msg) + self.assertIn("confidence_level 不得为高", msg) self.assertIn("盘中", msg) self.assertIn("不得当作完整日线复盘", msg) self.assertLess(msg.index("市场阶段上下文"), msg.index("分析上下文包摘要")) diff --git a/tests/test_analysis_api_contract.py b/tests/test_analysis_api_contract.py index 160f21c79..9f19af745 100644 --- a/tests/test_analysis_api_contract.py +++ b/tests/test_analysis_api_contract.py @@ -77,6 +77,20 @@ def _analysis_context_pack_overview() -> dict: "stale": 0, "estimated": 0, "partial": 0, + "fetch_failed": 0, + }, + "data_quality": { + "overall_score": 88, + "level": "good", + "block_scores": { + "quote": 100, + "daily_bars": 100, + "technical": 100, + "news": 35, + "fundamentals": 100, + "chip": 100, + }, + "limitations": [], }, "warnings": ["news_context_missing"], "metadata": { @@ -818,6 +832,10 @@ class AnalysisApiContractTestCase(unittest.TestCase): details["analysis_context_pack_overview"]["metadata"]["trigger_source"], "api", ) + self.assertEqual( + details["analysis_context_pack_overview"]["data_quality"]["overall_score"], + 88, + ) self.assertNotIn("analysis_context_pack_overview", details["context_snapshot"]) self.assertNotIn("market_phase_summary", details["context_snapshot"]) @@ -1001,6 +1019,10 @@ class AnalysisApiContractTestCase(unittest.TestCase): report.details.analysis_context_pack_overview.metadata.trigger_source, "api", ) + self.assertEqual( + report.details.analysis_context_pack_overview.data_quality.overall_score, + 88, + ) self.assertEqual( report.details.analysis_context_pack_overview.blocks[1].missing_reasons, ["news_context_missing"], @@ -1359,6 +1381,10 @@ class AnalysisApiContractTestCase(unittest.TestCase): status.result.report["details"]["analysis_context_pack_overview"]["metadata"]["trigger_source"], "api", ) + self.assertEqual( + status.result.report["details"]["analysis_context_pack_overview"]["data_quality"]["overall_score"], + 88, + ) self.assertNotIn( "analysis_context_pack_overview", status.result.report["details"]["context_snapshot"], @@ -1450,6 +1476,10 @@ class AnalysisApiContractTestCase(unittest.TestCase): status.result.report["details"]["analysis_context_pack_overview"]["metadata"]["trigger_source"], "api", ) + self.assertEqual( + status.result.report["details"]["analysis_context_pack_overview"]["data_quality"]["overall_score"], + 88, + ) self.assertNotIn( "analysis_context_pack_overview", status.result.report["details"]["context_snapshot"], diff --git a/tests/test_analysis_context_builder.py b/tests/test_analysis_context_builder.py index f12e96d19..4b0c3ea1c 100644 --- a/tests/test_analysis_context_builder.py +++ b/tests/test_analysis_context_builder.py @@ -356,7 +356,7 @@ def test_chip_missing_defaults_to_missing_and_explicit_not_supported() -> None: ("ok", ContextFieldStatus.AVAILABLE), ("not_supported", ContextFieldStatus.NOT_SUPPORTED), ("partial", ContextFieldStatus.PARTIAL), - ("failed", ContextFieldStatus.MISSING), + ("failed", ContextFieldStatus.FETCH_FAILED), ), ) def test_fundamentals_maps_supported_statuses_without_raw_errors( @@ -416,6 +416,46 @@ def test_news_block_treats_blank_as_missing_and_records_pack_metadata() -> None: assert available.metadata["news_result_count"] == 5 +def test_data_quality_scores_fixed_blocks_and_limits_auxiliary_missing() -> None: + pack = AnalysisContextBuilder.build(_artifacts()) + + assert pack.data_quality.overall_score == 100 + assert pack.data_quality.level == "good" + assert pack.data_quality.block_scores == { + "quote": 100, + "daily_bars": 100, + "technical": 100, + "news": 100, + "fundamentals": 100, + "chip": 100, + } + assert pack.data_quality.limitations == [] + + failed_fundamentals = AnalysisContextBuilder.build( + _artifacts( + fundamental_context={ + "status": "failed", + "coverage": {"valuation": "failed"}, + "source_chain": [ + {"provider": "fundamental_pipeline", "result": "failed"} + ], + } + ) + ) + assert failed_fundamentals.blocks["fundamentals"].status == ContextFieldStatus.FETCH_FAILED + assert failed_fundamentals.data_quality.block_scores["fundamentals"] == 25 + assert failed_fundamentals.data_quality.overall_score == 92 + assert failed_fundamentals.data_quality.level == "good" + assert failed_fundamentals.data_quality.limitations == ["fundamentals: fetch_failed"] + + blank_news = AnalysisContextBuilder.build( + _artifacts(news_context=" ", news_result_count=0) + ) + assert blank_news.blocks["news"].status == ContextFieldStatus.MISSING + assert blank_news.data_quality.block_scores["news"] == 35 + assert "news: missing" not in blank_news.data_quality.limitations + + def test_build_batch_returns_one_pack_per_artifact() -> None: packs = AnalysisContextBuilder.build_batch( [ diff --git a/tests/test_analysis_context_pack_docs.py b/tests/test_analysis_context_pack_docs.py index 5850672f7..555213a89 100644 --- a/tests/test_analysis_context_pack_docs.py +++ b/tests/test_analysis_context_pack_docs.py @@ -30,6 +30,7 @@ def test_analysis_context_pack_doc_has_required_sections() -> None: "## P2 Builder 契约", "## P3 Runtime Consumption", "## P4 历史记录、任务状态与 Web 可见性", + "## P5 数据质量评分与 Prompt 数据限制", "## 字段质量状态", "## 现有状态映射", "## 七路径盘点", @@ -65,9 +66,11 @@ def test_analysis_context_pack_doc_defines_p0_quality_states() -> None: "`stale`", "`estimated`", "`partial`", + "`fetch_failed`", ): assert state in section - assert "`fetch_failed`" not in section + assert "P0 先固定七词" in section + assert "P5 在同一 1.0 umbrella 内追加 `fetch_failed`" in section def test_analysis_context_pack_doc_covers_seven_paths() -> None: @@ -108,7 +111,7 @@ def test_analysis_context_pack_doc_records_non_goals_and_safety_boundaries() -> "不公开完整 pack", "不 pack 化 `market_review`", "`market_light`", - "`fetch_failed` 与 `not_supported` 的细分留到 P5", + "P5 已在同一 1.0 umbrella 内追加该状态", "`analysis_history.context_snapshot.enhanced_context.date`", "完整 pack 不默认公开", "API key", @@ -276,7 +279,7 @@ def test_analysis_context_pack_doc_defines_p3_runtime_consumption_boundaries() - "`analysis_context_pack_summary`", "Agent 工具级 pack cache 复用", "P4 在此基础上新增低敏 overview", - "通知展示和数据质量评分仍留给后续阶段", + "P5 继续复用 summary 消费路径", ): assert token in section @@ -307,13 +310,37 @@ def test_analysis_context_pack_doc_defines_p4_visibility_contract() -> None: "非零的其他状态计数", "不覆盖 pending/processing TaskPanel", "不改通知摘要", - "P5 数据质量评分", + "质量分/等级", + "`fetch_failed` 状态", ): assert token in section assert "运行诊断之后、策略点位之前" not in section +def test_analysis_context_pack_doc_defines_p5_data_quality_contract() -> None: + section = _section(_read_doc(), "P5 数据质量评分与 Prompt 数据限制") + + for token in ( + "`PACK_VERSION`", + "`fetch_failed`", + "`fundamental_context.status == \"failed\"`", + "`overall_score`", + "`level`", + "`block_scores`", + "`limitations`", + "`quote=25`", + "`fetch_failed=25`", + "`Data Limitations`", + "`confidence_level` 不得为 `高` / `High`", + "`analysis_context_pack_overview.data_quality`", + "`details.context_snapshot`", + "不新增 fetcher", + "不改变 LLM 输出 JSON schema", + ): + assert token in section + + def test_analysis_context_pack_doc_maps_existing_status_terms() -> None: section = _section(_read_doc(), "现有状态映射") @@ -361,18 +388,19 @@ def test_analysis_context_pack_doc_updates_indexes_and_changelog() -> None: changelog = (PROJECT_ROOT / "docs" / "CHANGELOG.md").read_text(encoding="utf-8") assert "[分析上下文包契约、运行态消费与可见性](analysis-context-pack.md)" in index - assert "P1/P2 内部契约、P3 Prompt 摘要消费、P4 历史/API/Web 低敏可见性" in index + assert "P1/P2 内部契约、P3 Prompt 摘要消费、P4 历史/API/Web 低敏可见性、P5 数据质量评分" in index assert ( "[Analysis Context Pack Contract, Runtime Consumption, And Visibility](analysis-context-pack.md) " - "![P4 Badge](https://img.shields.io/badge/P4-orange?style=flat) " + "![P5 Badge](https://img.shields.io/badge/P5-orange?style=flat) " "(Chinese-only)" ) in index_en - assert "P1/P2 internal contracts, P3 prompt-summary consumption, P4 history/API/Web low-sensitivity visibility" in index_en + assert "P1/P2 internal contracts, P3 prompt-summary consumption, P4 history/API/Web low-sensitivity visibility, P5 data-quality scoring" in index_en assert "新增 AnalysisContextPack P0 上下文盘点" in changelog assert "新增 AnalysisContextPack P1 内部契约与脱敏序列化测试" in changelog assert "新增 AnalysisContextPack P2 builder" in changelog assert "普通分析与 Agent 运行时 Prompt 接入 AnalysisContextPack 低敏摘要" in changelog assert "AnalysisContextPack P4 低敏 overview 接入历史详情" in changelog + assert "AnalysisContextPack P5 增加数据质量评分" in changelog assert "优化 Web 报告详情页信息层级" in changelog @@ -389,6 +417,9 @@ def test_full_guides_clarify_pack_summary_does_not_replace_legacy_payload_channe assert "折叠头部展示可用数、缺失数、非零的其他状态计数和触发来源" in guide assert "Web 报告页在策略点位和资讯之后默认折叠展示数据块状态" in guide assert "`details.context_snapshot` 会剥离顶层 `analysis_context_pack_overview`" in guide + assert "AnalysisContextPack 数据质量评分与 Prompt 数据限制(Issue #1389 P5)" in guide + assert "`fetch_failed`" in guide + assert "折叠头部新增质量分/等级" in guide assert "`report.meta.market_phase_summary`" in guide assert "`details.context_snapshot` 会剥离顶层 `market_phase_summary`" in guide @@ -401,5 +432,8 @@ def test_full_guides_clarify_pack_summary_does_not_replace_legacy_payload_channe assert "available/missing counts, non-zero other status counts, and trigger source" in guide_en assert "the Web report page shows the data-block summary collapsed after Strategy and News" in guide_en assert "API `details.context_snapshot` strips the top-level `analysis_context_pack_overview`" in guide_en + assert "AnalysisContextPack Data Quality Scoring and Prompt Limitations (Issue #1389 P5)" in guide_en + assert "`fetch_failed`" in guide_en + assert "adds quality score/level to the header" in guide_en assert "`report.meta.market_phase_summary`" in guide_en assert "API `details.context_snapshot` strips the top-level `market_phase_summary`" in guide_en diff --git a/tests/test_analysis_context_pack_overview.py b/tests/test_analysis_context_pack_overview.py index 4b813c1ed..b6c10863f 100644 --- a/tests/test_analysis_context_pack_overview.py +++ b/tests/test_analysis_context_pack_overview.py @@ -95,6 +95,17 @@ def _pack() -> AnalysisContextPack: ), }, data_quality=DataQuality( + overall_score=76, + level="usable", + block_scores={ + "quote": 65, + "daily_bars": 100, + "technical": 75, + "news": 35, + "fundamentals": 100, + "chip": 100, + }, + limitations=["quote: fallback", "technical: partial"], warnings=["intraday_realtime_overlay", "intraday_realtime_overlay"] ), metadata={ @@ -129,6 +140,7 @@ def test_renderer_outputs_only_public_schema_fields() -> None: "subject", "blocks", "counts", + "data_quality", "warnings", "metadata", } @@ -142,6 +154,25 @@ def test_renderer_outputs_only_public_schema_fields() -> None: "warnings", "missing_reasons", } + assert set(overview["data_quality"]) == { + "overall_score", + "level", + "block_scores", + "limitations", + } + assert overview["data_quality"] == { + "overall_score": 76, + "level": "usable", + "block_scores": { + "quote": 65, + "daily_bars": 100, + "technical": 75, + "news": 35, + "fundamentals": 100, + "chip": 100, + }, + "limitations": ["quote: fallback", "technical: partial"], + } def test_renderer_does_not_dump_items_values_payloads_or_sensitive_markers() -> None: @@ -175,6 +206,7 @@ def test_counts_are_by_block_status_and_missing_reasons_are_deduped() -> None: "stale": 0, "estimated": 0, "partial": 1, + "fetch_failed": 0, } news_block = next(block for block in overview["blocks"] if block["key"] == "news") assert news_block["missing_reasons"] == [ @@ -271,6 +303,24 @@ def test_extract_reprojects_persisted_overview_to_public_schema() -> None: "stale": 999, "estimated": 999, "partial": 999, + "fetch_failed": 999, + }, + "data_quality": { + "overall_score": 76, + "level": "usable", + "block_scores": { + "quote": 65, + "news": 35, + "api_key": 99, + "technical": 999, + }, + "limitations": [ + "quote: fallback", + "token=secret should not pass", + "technical: partial", + "technical: partial", + ], + "warnings": ["not-public"], }, "warnings": ["top_warning", "top_warning"], "metadata": { @@ -293,6 +343,17 @@ def test_extract_reprojects_persisted_overview_to_public_schema() -> None: "stale": 0, "estimated": 0, "partial": 0, + "fetch_failed": 0, + } + assert extracted["data_quality"] == { + "overall_score": 76, + "level": "usable", + "block_scores": {"quote": 65, "news": 35}, + "limitations": [ + "quote: fallback", + "[REDACTED]", + "technical: partial", + ], } assert extracted["blocks"][1]["missing_reasons"] == [ "news_context_missing", @@ -308,6 +369,32 @@ def test_extract_reprojects_persisted_overview_to_public_schema() -> None: assert "secret-key" not in rendered +def test_extract_accepts_legacy_overview_without_data_quality() -> None: + extracted = extract_analysis_context_pack_overview( + { + "analysis_context_pack_overview": { + "pack_version": "1.0", + "subject": {"code": "600519"}, + "blocks": [ + { + "key": "quote", + "label": "行情", + "status": "available", + "source": "mock", + "warnings": [], + "missing_reasons": [], + } + ], + "metadata": {}, + } + } + ) + + assert extracted is not None + assert "data_quality" not in extracted + assert extracted["counts"]["fetch_failed"] == 0 + + def test_extract_returns_none_for_malformed_persisted_overview() -> None: assert extract_analysis_context_pack_overview( { diff --git a/tests/test_analysis_context_pack_prompt.py b/tests/test_analysis_context_pack_prompt.py index 1eda55171..d4c9ee423 100644 --- a/tests/test_analysis_context_pack_prompt.py +++ b/tests/test_analysis_context_pack_prompt.py @@ -12,6 +12,10 @@ from src.schemas.analysis_context_pack import ( ContextFieldStatus, DataQuality, ) +from src.services.analysis_context_builder import ( + AnalysisContextBuilder, + PipelineAnalysisArtifacts, +) def _pack() -> AnalysisContextPack: @@ -71,7 +75,20 @@ def _pack() -> AnalysisContextPack: }, ), }, - data_quality=DataQuality(warnings=["intraday_realtime_overlay"]), + data_quality=DataQuality( + overall_score=76, + level="usable", + block_scores={ + "quote": 65, + "daily_bars": 100, + "technical": 75, + "news": 35, + "fundamentals": 100, + "chip": 100, + }, + limitations=["quote: fallback", "technical: partial"], + warnings=["intraday_realtime_overlay"], + ), metadata={ "query_id": "q-1", "trigger_source": "api", @@ -81,6 +98,28 @@ def _pack() -> AnalysisContextPack: ) +def _builder_artifacts(*, fundamental_context: dict) -> PipelineAnalysisArtifacts: + return PipelineAnalysisArtifacts( + code="600519", + stock_name="贵州茅台", + market="cn", + phase=None, + base_context={ + "today": {"close": 1880.0}, + "yesterday": {"close": 1870.0}, + "date": "2026-03-26", + }, + enhanced_context={}, + realtime_quote={"price": 1880.0, "source": "mock_quote"}, + trend_result={"trend_status": "available"}, + chip_data={"source": "mock_chip", "date": "2026-03-26"}, + fundamental_context=fundamental_context, + news_context="新闻摘要", + news_result_count=1, + metadata={"trigger_source": "api"}, + ) + + def test_empty_or_invalid_pack_returns_empty_section() -> None: assert format_analysis_context_pack_prompt_section(None) == "" assert format_analysis_context_pack_prompt_section({}) == "" @@ -100,6 +139,10 @@ def test_chinese_summary_renders_low_sensitivity_pack_statuses() -> None: assert "news_context_missing" in section assert "新闻结果数:3" in section assert "intraday_realtime_overlay" in section + assert "数据限制" in section + assert "数据质量评分:76/100(可用)" in section + assert "已知限制:行情:降级、技术:部分可用" in section + assert "confidence_level 不得为高" in section def test_english_summary_renders_readable_statuses() -> None: @@ -113,6 +156,10 @@ def test_english_summary_renders_readable_statuses() -> None: assert "quote: fallback" in section assert "news: missing" in section assert "News result count: 3" in section + assert "Data Limitations" in section + assert "Data quality score: 76/100 (usable)" in section + assert "Known limitations: quote: fallback, technical: partial" in section + assert "confidence_level must not be High" in section def test_summary_does_not_dump_values_or_sensitive_payloads() -> None: @@ -125,3 +172,28 @@ def test_summary_does_not_dump_values_or_sensitive_payloads() -> None: assert "hooks.example.test" not in section assert "webhook_url" not in section assert "access_token" not in section + assert "N/A" not in section + assert "None" not in section + + +def test_builder_to_prompt_renders_aux_fetch_failed_without_confidence_cap() -> None: + pack = AnalysisContextBuilder.build( + _builder_artifacts( + fundamental_context={ + "status": "failed", + "coverage": {"valuation": "failed"}, + "source_chain": [ + {"provider": "fundamental_pipeline", "result": "failed"} + ], + } + ) + ) + + section = format_analysis_context_pack_prompt_section(pack) + + assert pack.data_quality.limitations == ["fundamentals: fetch_failed"] + assert "数据限制" in section + assert "数据质量评分:92/100(良好)" in section + assert "已知限制:基本面:抓取失败" in section + assert "置信度规则" not in section + assert "confidence_level" not in section diff --git a/tests/test_analysis_context_pack_schema.py b/tests/test_analysis_context_pack_schema.py index c27f245fb..953c8ef28 100644 --- a/tests/test_analysis_context_pack_schema.py +++ b/tests/test_analysis_context_pack_schema.py @@ -42,7 +42,14 @@ def test_pack_defaults_and_json_serialization_are_stable() -> None: "market": "cn", } assert dumped["blocks"] == {} - assert dumped["data_quality"] == {"warnings": [], "metadata": {}} + assert dumped["data_quality"] == { + "overall_score": None, + "level": None, + "block_scores": {}, + "limitations": [], + "warnings": [], + "metadata": {}, + } assert dumped["metadata"] == {} assert dumped["created_at"] == "2026-05-24T09:30:00Z" @@ -140,22 +147,26 @@ def test_item_and_block_reject_invalid_assignment_updates() -> None: with pytest.raises(ValidationError): item.timestamp = "yesterday" - with pytest.raises(ValidationError): - item.status = "fetch_failed" + item.status = "fetch_failed" with pytest.raises(ValidationError): block.timestamp = "2026/05/24" + block.status = "fetch_failed" + with pytest.raises(ValidationError): - block.status = "fetch_failed" + item.status = "bad_status" + + with pytest.raises(ValidationError): + block.status = "bad_status" assert item.timestamp == "2026-05-24T09:30:00+08:00" - assert item.status == ContextFieldStatus.AVAILABLE + assert item.status == ContextFieldStatus.FETCH_FAILED assert block.timestamp == "2026-05-24T09:30:01+08:00" - assert block.status == ContextFieldStatus.AVAILABLE + assert block.status == ContextFieldStatus.FETCH_FAILED -def test_context_field_status_allows_only_p0_quality_states() -> None: +def test_context_field_status_allows_current_quality_states() -> None: for state in ( "available", "missing", @@ -164,14 +175,15 @@ def test_context_field_status_allows_only_p0_quality_states() -> None: "stale", "estimated", "partial", + "fetch_failed", ): assert ContextFieldStatus(state).value == state with pytest.raises(ValueError): - ContextFieldStatus("fetch_failed") + ContextFieldStatus("bad_status") with pytest.raises(ValidationError): - AnalysisContextItem(status="fetch_failed") + AnalysisContextItem(status="bad_status") def test_market_phase_context_dict_can_be_used_as_phase_slot() -> None: @@ -209,15 +221,23 @@ def test_block_and_item_status_are_independent_contract_fields() -> None: assert dumped["items"]["turnover_rate"]["status"] == "missing" -def test_data_quality_is_container_only() -> None: +def test_data_quality_serializes_p5_scoring_fields_and_legacy_fields() -> None: data_quality = DataQuality( + overall_score=72, + level="usable", + block_scores={"quote": 65}, + limitations=["quote: fallback"], warnings=["quote_stale"], - metadata={"note": "P1 does not define scoring"}, + metadata={"note": "P5 scoring is low sensitivity"}, ) assert data_quality.model_dump(mode="json") == { + "overall_score": 72, + "level": "usable", + "block_scores": {"quote": 65}, + "limitations": ["quote: fallback"], "warnings": ["quote_stale"], - "metadata": {"note": "P1 does not define scoring"}, + "metadata": {"note": "P5 scoring is low sensitivity"}, } diff --git a/tests/test_analysis_history.py b/tests/test_analysis_history.py index cbe0978ce..1b6d3d61e 100644 --- a/tests/test_analysis_history.py +++ b/tests/test_analysis_history.py @@ -66,6 +66,20 @@ def _analysis_context_pack_overview() -> dict: "stale": 0, "estimated": 0, "partial": 0, + "fetch_failed": 0, + }, + "data_quality": { + "overall_score": 100, + "level": "good", + "block_scores": { + "quote": 100, + "daily_bars": 100, + "technical": 100, + "news": 100, + "fundamentals": 100, + "chip": 100, + }, + "limitations": [], }, "warnings": [], "metadata": { @@ -901,6 +915,10 @@ class AnalysisHistoryTestCase(unittest.TestCase): report.details.analysis_context_pack_overview.metadata.trigger_source, "api", ) + self.assertEqual( + report.details.analysis_context_pack_overview.data_quality.overall_score, + 100, + ) self.assertIsNotNone(report.meta.market_phase_summary) self.assertEqual(report.meta.market_phase_summary.phase, "intraday") self.assertEqual(report.meta.market_phase_summary.minutes_to_close, 300)