feat: add analysis context data quality scoring (#1539)

This commit is contained in:
Alfred
2026-05-31 23:10:30 +08:00
committed by GitHub
parent 9f14850265
commit 3e98dfd9bc
22 changed files with 909 additions and 48 deletions

View File

@@ -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)

View File

@@ -25,6 +25,33 @@ const STATUS_STYLE: Record<AnalysisContextPackBlockStatus, { variant: BadgeVaria
stale: { variant: 'warning', tone: 'warning' },
estimated: { variant: 'info', tone: 'info' },
partial: { variant: 'warning', tone: 'warning' },
fetch_failed: { variant: 'danger', tone: 'danger' },
};
const QUALITY_STYLE = {
good: { variant: 'success', tone: 'success' },
usable: { variant: 'info', tone: 'info' },
limited: { variant: 'warning', tone: 'warning' },
poor: { variant: 'danger', tone: 'danger' },
} as const satisfies Record<string, { variant: BadgeVariant; tone: StatusTone }>;
const BLOCK_LABELS: Record<ReportLanguage, Record<string, string>> = {
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<string, string>)[status] || status;
return language === 'zh' ? `${label}${statusLabel}` : `${label}: ${statusLabel}`;
};
export const AnalysisContextSummary: React.FC<AnalysisContextSummaryProps> = ({
overview,
language = 'zh',
@@ -111,6 +181,11 @@ export const AnalysisContextSummary: React.FC<AnalysisContextSummaryProps> = ({
: 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 (
<Card variant="bordered" padding="none" className="home-panel-card">
@@ -128,6 +203,12 @@ export const AnalysisContextSummary: React.FC<AnalysisContextSummaryProps> = ({
</span>
</div>
<span className="flex min-w-0 flex-wrap items-center justify-end gap-2">
{typeof quality?.overallScore === 'number' ? (
<Badge variant={qualityStyle?.variant || 'default'} className="gap-1.5 shadow-none">
{qualityStyle ? <StatusDot tone={qualityStyle.tone} className="h-1.5 w-1.5" /> : null}
{text.qualityScore} {quality.overallScore}/100{qualityLabel ? ` ${qualityLabel}` : ''}
</Badge>
) : null}
{summaryCounts.map(({ status, value }) => {
const style = STATUS_STYLE[status];
return (
@@ -155,8 +236,13 @@ export const AnalysisContextSummary: React.FC<AnalysisContextSummaryProps> = ({
<Database className="h-4 w-4" aria-hidden="true" />
</span>
)}
actions={metadataItems.length > 0 ? (
actions={metadataItems.length > 0 || typeof quality?.overallScore === 'number' ? (
<div className="hidden flex-wrap justify-end gap-2 text-xs text-muted-text md:flex">
{typeof quality?.overallScore === 'number' ? (
<span className="home-accent-chip px-2 py-0.5">
{text.qualityScore}: {quality.overallScore}/100{qualityLabel ? ` ${qualityLabel}` : ''}
</span>
) : null}
{metadataItems.map((item) => (
<span key={item} className="home-accent-chip px-2 py-0.5">
{item}
@@ -181,6 +267,13 @@ export const AnalysisContextSummary: React.FC<AnalysisContextSummaryProps> = ({
</div>
) : null}
{limitations.length ? (
<div className="mb-3 home-subpanel p-3 text-xs leading-5 text-muted-text">
<span className="font-medium text-foreground">{text.limitations}: </span>
{limitations.join(', ')}
</div>
) : null}
{overview.warnings?.length ? (
<div className="mb-3 home-subpanel p-3 text-xs leading-5 text-warning">
<span className="font-medium">{text.warnings}: </span>
@@ -223,8 +316,13 @@ export const AnalysisContextSummary: React.FC<AnalysisContextSummaryProps> = ({
})}
</div>
{metadataItems.length > 0 ? (
{metadataItems.length > 0 || typeof quality?.overallScore === 'number' ? (
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-text md:hidden">
{typeof quality?.overallScore === 'number' ? (
<span className="home-accent-chip px-2 py-0.5">
{text.qualityScore}: {quality.overallScore}/100{qualityLabel ? ` ${qualityLabel}` : ''}
</span>
) : null}
{metadataItems.map((item) => (
<span key={item} className="home-accent-chip px-2 py-0.5">
{item}

View File

@@ -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,
},
};

View File

@@ -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<string, number>;
limitations: string[];
}
export interface AnalysisContextPackOverview {
packVersion: string;
createdAt?: string | null;
subject: AnalysisContextPackOverviewSubject;
blocks: AnalysisContextPackOverviewBlock[];
counts: AnalysisContextPackOverviewCounts;
dataQuality?: AnalysisContextPackOverviewDataQuality | null;
warnings: string[];
metadata: AnalysisContextPackOverviewMetadata;
}

View File

@@ -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 设置页仅展示已注册且带说明的配置项。

View File

@@ -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 外部集成说明 |

View File

@@ -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/) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | Feishu, DingTalk, Discord, and related Bot configuration screenshots and notes |
| [Real-Time Alert Center](alerts.md) <sub><sub>![P4 Badge](https://img.shields.io/badge/P4-yellow?style=flat)</sub></sub> (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) <sub><sub>![P4 Badge](https://img.shields.io/badge/P4-orange?style=flat)</sub></sub> (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) <sub><sub>![P5 Badge](https://img.shields.io/badge/P5-orange?style=flat)</sub></sub> (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) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | Prompt and boundaries for extracting stock information from images |
| [OpenClaw Skill Integration](openclaw-skill-integration.md) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | OpenClaw / Skill external integration notes |

View File

@@ -1,6 +1,6 @@
# AnalysisContextPackP0 盘点、P1/P2 契约、P3 Runtime ConsumptionP4 可见性
# AnalysisContextPackP0 盘点、P1/P2 契约、P3 Runtime ConsumptionP4 可见性与 P5 数据质量
本页是 Issue #1389 的专题文档,用于记录当前 DSA 分析上下文的真实来源、消费路径、字段状态边界,以及 `AnalysisContextPack` 内部契约、builder、运行态消费低敏可见性边界。P0 负责现状盘点和契约边界P1 只新增内部 schema/envelope、block catalog、类型约定和脱敏序列化P2 只从 pipeline 已有 artifacts 组装 packP3 只把低敏摘要接入普通分析和 Agent 初始 PromptP4 只把低敏 overview 接入历史详情、同步分析响应、completed task status 和 Web 报告页。
本页是 Issue #1389 的专题文档,用于记录当前 DSA 分析上下文的真实来源、消费路径、字段状态边界,以及 `AnalysisContextPack` 内部契约、builder、运行态消费低敏可见性和数据质量评分边界。P0 负责现状盘点和契约边界P1 只新增内部 schema/envelope、block catalog、类型约定和脱敏序列化P2 只从 pipeline 已有 artifacts 组装 packP3 只把低敏摘要接入普通分析和 Agent 初始 PromptP4 只把低敏 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/estimatedwarning 使用 `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 metadatahistory 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 metadatahistory 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 reasonblock status count。
- 完整 pack 不默认公开到历史、API、Web 或通知P4/P5 只公开 `analysis_context_pack_overview` 低敏摘要、来源、fallback、stale、missing reasonblock 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。

View File

@@ -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}` 都会返回同一份低敏 overviewWeb 端报告页在“策略点位”和“资讯”之后展示默认折叠的数据块摘要折叠头部展示可用数、缺失数、非零的其他状态计数和触发来源展开后展示数据块状态、来源、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

View File

@@ -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`.
---

View File

@@ -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 ""

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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("分析上下文包摘要"))

View File

@@ -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"],

View File

@@ -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(
[

View File

@@ -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) "
"<sub><sub>![P4 Badge](https://img.shields.io/badge/P4-orange?style=flat)</sub></sub> "
"<sub><sub>![P5 Badge](https://img.shields.io/badge/P5-orange?style=flat)</sub></sub> "
"(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

View File

@@ -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(
{

View File

@@ -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

View File

@@ -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"},
}

View File

@@ -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)