feat: 支持保存决策风格重评估结果 (#2014)

* feat: persist decision profile reassessment signals

* fix: align reassess persistence outcomes and lifecycle
This commit is contained in:
Alfred
2026-07-16 19:22:14 +08:00
committed by GitHub
parent 55946536a9
commit 487e49e565
18 changed files with 2154 additions and 112 deletions

View File

@@ -22,6 +22,7 @@ from api.v1.schemas.decision_signals import (
DecisionSignalOutcomeRunResponse,
DecisionSignalOutcomeStatsResponse,
DecisionSignalReassessRequest,
DecisionSignalReassessErrorResponse,
DecisionSignalReassessResponse,
DecisionSignalStatusUpdateRequest,
)
@@ -33,9 +34,8 @@ from src.services.decision_signal_service import (
)
from src.services.decision_signal_outcome_service import DecisionSignalOutcomeService
from src.services.decision_signal_reassess_service import (
UNSUPPORTED_PERSIST_MESSAGE,
DecisionSignalReassessGuardrailBlockedError,
DecisionSignalReassessService,
DecisionSignalReassessUnsupportedOperationError,
DecisionSignalSourceReportNotFoundError,
DecisionSignalUnsupportedReportSnapshotError,
DecisionSignalUnsupportedReportTypeError,
@@ -88,6 +88,16 @@ def _internal_error(message: str, exc: Exception) -> HTTPException:
)
def _guardrail_blocked(exc: DecisionSignalReassessGuardrailBlockedError) -> HTTPException:
response = DecisionSignalReassessErrorResponse(
error="guardrail_blocked",
message="Reassessed decision signal was blocked by guardrail.",
blocked_reason=exc.blocked_reason,
warnings=exc.warnings,
)
return HTTPException(status_code=400, detail=response.model_dump())
@router.post(
"",
response_model=DecisionSignalMutationResponse,
@@ -318,26 +328,19 @@ def get_outcome_stats(
response_model=DecisionSignalReassessResponse,
responses={
**AUTH_RESPONSE,
400: {"model": ErrorResponse, "description": "重评估请求不支持或历史报告不适用"},
400: {"model": DecisionSignalReassessErrorResponse, "description": "历史报告不适用或持久化被风控阻断"},
404: {"model": ErrorResponse, "description": "来源历史报告不存在"},
422: {"model": ErrorResponse, "description": "请求体校验失败"},
500: {"model": ErrorResponse, "description": "重评估失败"},
},
summary="预览决策风格重评估",
summary="重评估决策风格并可选保存",
description=(
"基于 source_report_id 对应的持久化历史报告快照生成 decision_profile preview"
"P3a 仅支持 persist=false,不写入 DecisionSignal。"
"基于 source_report_id 对应的持久化历史报告快照重新计算 decision_profile 信号"
"persist=false 返回只读 previewpersist=true 将通过 guardrail 的服务端结果写入 DecisionSignal。"
),
operation_id="reassessDecisionSignalPreview",
)
def reassess_signal(request: DecisionSignalReassessRequest) -> DecisionSignalReassessResponse:
if request.persist:
raise _error(
400,
DecisionSignalReassessUnsupportedOperationError(UNSUPPORTED_PERSIST_MESSAGE),
error="unsupported_operation",
)
service = DecisionSignalReassessService()
try:
return DecisionSignalReassessResponse(
@@ -353,10 +356,10 @@ def reassess_signal(request: DecisionSignalReassessRequest) -> DecisionSignalRea
raise _error(400, exc, error="unsupported_report_type")
except DecisionSignalUnsupportedReportSnapshotError as exc:
raise _error(400, exc, error="unsupported_report_snapshot")
except DecisionSignalReassessUnsupportedOperationError as exc:
raise _error(400, exc, error="unsupported_operation")
except DecisionSignalReassessGuardrailBlockedError as exc:
raise _guardrail_blocked(exc)
except Exception as exc:
raise _internal_error("Reassess decision signal preview failed", exc)
raise _internal_error("Reassess decision signal failed", exc)
@router.get(

View File

@@ -261,13 +261,25 @@ class DecisionSignalMutationResponse(BaseModel):
class DecisionSignalReassessResponse(BaseModel):
preview: DecisionSignalPreview
preview: Optional[DecisionSignalPreview] = None
item: Optional[DecisionSignalItem] = None
created: bool = False
persist_status: Optional[Literal["created", "existing", "refreshed"]] = None
warnings: List[DecisionSignalWarning] = Field(default_factory=list)
blocked_reason: Optional[str] = None
class DecisionSignalReassessErrorResponse(BaseModel):
error: Literal[
"unsupported_report_type",
"unsupported_report_snapshot",
"guardrail_blocked",
]
message: str
blocked_reason: Optional[str] = None
warnings: List[DecisionSignalWarning] = Field(default_factory=list)
class DecisionSignalListResponse(BaseModel):
items: List[DecisionSignalItem] = Field(default_factory=list)
total: int

View File

@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { decisionSignalsApi } from '../decisionSignals';
import {
decisionSignalsApi,
getDecisionSignalReassessBlockedError,
} from '../decisionSignals';
const { get, post, patch, put } = vi.hoisted(() => ({
get: vi.fn(),
@@ -273,6 +276,7 @@ describe('decisionSignalsApi', () => {
},
item: null,
created: false,
persist_status: null,
warnings: [
{
code: 'action_blocked_by_guardrail',
@@ -293,8 +297,8 @@ describe('decisionSignalsApi', () => {
decision_profile: 'aggressive',
persist: false,
});
expect(response.preview.entryLow).toBe(1680);
expect(response.preview.metadata).toEqual({
expect(response.preview!.entryLow).toBe(1680);
expect(response.preview!.metadata).toEqual({
decision_profile: 'aggressive',
data_quality_level: 'medium',
scoring_breakdown: { raw_action: 'buy' },
@@ -308,6 +312,89 @@ describe('decisionSignalsApi', () => {
},
});
expect(response.blockedReason).toBe('actionable_signal_blocked_by_guardrail');
expect(response.persistStatus).toBeNull();
});
it('persists reassess and parses the authoritative server item', async () => {
post.mockResolvedValueOnce({
data: {
preview: null,
item: {
id: 88,
stock_code: '600519',
stock_name: '贵州茅台',
market: 'cn',
source_type: 'analysis',
source_report_id: 3001,
source_agent: 'decision_profile_reassess',
decision_profile: 'aggressive',
trigger_source: 'web:decision_profile_reassess',
action: 'watch',
plan_quality: 'partial',
status: 'active',
metadata: {
decision_profile: 'aggressive',
guardrail_result: { raw_action: 'buy', final_action: 'watch', passed: true },
},
},
created: true,
persist_status: 'created',
warnings: [{ code: 'action_adjusted_by_guardrail', message: '已调整。' }],
blocked_reason: null,
},
});
const response = await decisionSignalsApi.reassess({
sourceReportId: 3001,
decisionProfile: 'aggressive',
persist: true,
});
expect(post).toHaveBeenCalledWith('/api/v1/decision-signals/reassess', {
source_report_id: 3001,
decision_profile: 'aggressive',
persist: true,
});
expect(response.preview).toBeNull();
expect(response.item?.sourceReportId).toBe(3001);
expect(response.item?.sourceAgent).toBe('decision_profile_reassess');
expect(response.item?.metadata).toEqual({
decision_profile: 'aggressive',
guardrail_result: { raw_action: 'buy', final_action: 'watch', passed: true },
});
expect(response.created).toBe(true);
expect(response.persistStatus).toBe('created');
});
it('extracts structured guardrail blocked errors', () => {
const error = {
response: {
data: {
error: 'guardrail_blocked',
message: 'blocked',
blocked_reason: 'invalid_price_relationships',
warnings: [
{
code: 'action_blocked_by_guardrail',
message: '价格关系矛盾,未保存。',
params: { violations: ['stop_loss_not_below_target_price'] },
},
],
},
},
};
expect(getDecisionSignalReassessBlockedError(error)).toEqual({
blockedReason: 'invalid_price_relationships',
warnings: [
{
code: 'action_blocked_by_guardrail',
message: '价格关系矛盾,未保存。',
params: { violations: ['stop_loss_not_below_target_price'] },
},
],
});
expect(getDecisionSignalReassessBlockedError({ response: { data: { error: 'other' } } })).toBeNull();
});
it('rejects malformed list responses instead of treating missing items as empty', async () => {

View File

@@ -18,6 +18,7 @@ import type {
DecisionSignalOutcomeStatsParams,
DecisionSignalOutcomeStatsResponse,
DecisionSignalReassessRequest,
DecisionSignalReassessBlockedError,
DecisionSignalReassessResponse,
DecisionSignalStatusUpdateRequest,
} from '../types/decisionSignals';
@@ -56,17 +57,47 @@ function toDecisionSignalMutationResponse(data: Record<string, unknown>): Decisi
function toDecisionSignalReassessResponse(data: Record<string, unknown>): DecisionSignalReassessResponse {
const response = toCamelCase<DecisionSignalReassessResponse>(data);
const rawPreview = data.preview as Record<string, unknown> | undefined;
if (!rawPreview || typeof rawPreview !== 'object') {
const rawPreview = data.preview;
if (rawPreview !== null && (typeof rawPreview !== 'object' || Array.isArray(rawPreview))) {
throw new Error('DecisionSignal reassess response preview must be an object');
}
response.preview.metadata = (rawPreview.metadata as Record<string, unknown> | undefined) ?? {};
if (rawPreview) {
response.preview = toCamelCase<DecisionSignalReassessResponse['preview']>(rawPreview);
if (response.preview) {
response.preview.metadata = (rawPreview as Record<string, unknown>).metadata as Record<string, unknown> ?? {};
}
} else {
response.preview = null;
}
if (data.item) {
response.item = toDecisionSignalItem(data.item as Record<string, unknown>);
}
return response;
}
export function getDecisionSignalReassessBlockedError(
error: unknown,
): DecisionSignalReassessBlockedError | null {
if (!error || typeof error !== 'object') return null;
const response = (error as { response?: { data?: unknown } }).response;
const data = response?.data;
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
const payload = data as Record<string, unknown>;
if (payload.error !== 'guardrail_blocked' || typeof payload.blocked_reason !== 'string') return null;
const warnings = Array.isArray(payload.warnings)
? payload.warnings.filter((warning): warning is Record<string, unknown> => (
Boolean(warning) && typeof warning === 'object' && !Array.isArray(warning)
)).filter((warning) => typeof warning.code === 'string').map((warning) => ({
code: warning.code as string,
message: typeof warning.message === 'string' ? warning.message : undefined,
params: warning.params && typeof warning.params === 'object' && !Array.isArray(warning.params)
? warning.params as Record<string, unknown>
: undefined,
}))
: [];
return { blockedReason: payload.blocked_reason, warnings };
}
function toDecisionSignalListResponse(data: Record<string, unknown>): DecisionSignalListResponse {
const response = toCamelCase<DecisionSignalListResponse>(data);
if (!Array.isArray(data.items)) {
@@ -177,7 +208,7 @@ function toSnakeReassessPayload(payload: DecisionSignalReassessRequest): Record<
return {
source_report_id: payload.sourceReportId,
decision_profile: payload.decisionProfile,
persist: false,
persist: payload.persist ?? false,
};
}

View File

@@ -284,6 +284,19 @@ const zh = {
'decisionSignals.reason': '理由',
'decisionSignals.reassessBlockedNote': '该预览已被风控约束为非进攻展示动作。',
'decisionSignals.reassessBlockedTitle': '预览被风控阻断',
'decisionSignals.reassessPersist': '确认保存',
'decisionSignals.reassessPersistBlockedTitle': '保存被风控阻断',
'decisionSignals.reassessPersistConfirmMessage': '服务端将基于同一份历史报告快照重新计算,并保存通过风控的结果。',
'decisionSignals.reassessPersistConfirmTitle': '保存重评估信号',
'decisionSignals.reassessPersistedCreated': '已保存为新的 DecisionSignal #{id}。',
'decisionSignals.reassessPersistedCreatedTitle': '重评估信号已保存',
'decisionSignals.reassessPersistedExisting': '同一报告、风格和信号身份的 DecisionSignal #{id} 已存在,本次没有重复创建;展示其原始服务端记录。',
'decisionSignals.reassessPersistedExistingTitle': '已复用现有信号',
'decisionSignals.reassessPersistedRefreshed': '现有 DecisionSignal #{id} 已按存储契约完成过期续期或缺失维度补齐;原始创建来源保持不变,请以后端返回记录为准。',
'decisionSignals.reassessPersistedRefreshedTitle': '重评估信号已刷新',
'decisionSignals.reassessPersistedTerminalExisting': 'DecisionSignal #{id} 已处于“{status}”状态,本次没有新建或重新激活信号。',
'decisionSignals.reassessPersistedTerminalTitle': '现有信号保持终态',
'decisionSignals.reassessPersisting': '正在保存',
'decisionSignals.reassessPreview': '生成预览',
'decisionSignals.reassessProfile': '重评估风格',
'decisionSignals.reassessRawFinal': '原始/最终',
@@ -1122,6 +1135,19 @@ const en: Record<UiTextKey, string> = {
'decisionSignals.reason': 'Reason',
'decisionSignals.reassessBlockedNote': 'This preview is constrained to a non-aggressive display action.',
'decisionSignals.reassessBlockedTitle': 'Preview blocked by guardrail',
'decisionSignals.reassessPersist': 'Confirm and save',
'decisionSignals.reassessPersistBlockedTitle': 'Save blocked by guardrail',
'decisionSignals.reassessPersistConfirmMessage': 'The server will recompute from the same persisted report snapshot and save only a guardrail-approved result.',
'decisionSignals.reassessPersistConfirmTitle': 'Save reassessed signal',
'decisionSignals.reassessPersistedCreated': 'Saved as a new DecisionSignal #{id}.',
'decisionSignals.reassessPersistedCreatedTitle': 'Reassessed signal saved',
'decisionSignals.reassessPersistedExisting': 'DecisionSignal #{id} already exists for the same report, profile, and signal identity. No duplicate was created; the original server record is shown.',
'decisionSignals.reassessPersistedExistingTitle': 'Existing signal reused',
'decisionSignals.reassessPersistedRefreshed': 'DecisionSignal #{id} was refreshed under the storage contract by renewing an expired record or filling missing identity dimensions. Original creation provenance is preserved; use the returned server record as authoritative.',
'decisionSignals.reassessPersistedRefreshedTitle': 'Reassessed signal refreshed',
'decisionSignals.reassessPersistedTerminalExisting': 'DecisionSignal #{id} is already {status}. No signal was created or reactivated.',
'decisionSignals.reassessPersistedTerminalTitle': 'Existing signal remains terminal',
'decisionSignals.reassessPersisting': 'Saving',
'decisionSignals.reassessPreview': 'Generate preview',
'decisionSignals.reassessProfile': 'Reassess profile',
'decisionSignals.reassessRawFinal': 'Raw / final',

View File

@@ -1,7 +1,10 @@
import type React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Activity, BarChart3, RefreshCw, Search, ShieldCheck } from 'lucide-react';
import { decisionSignalsApi } from '../api/decisionSignals';
import {
decisionSignalsApi,
getDecisionSignalReassessBlockedError,
} from '../api/decisionSignals';
import { getParsedApiError, type ParsedApiError } from '../api/error';
import { historyApi } from '../api/history';
import {
@@ -34,6 +37,7 @@ import type {
DecisionSignalOutcomeItem,
DecisionSignalOutcomeStatsResponse,
DecisionSignalReassessResponse,
DecisionSignalReassessBlockedError,
DecisionSignalSourceType,
DecisionSignalStatus,
DecisionProfile,
@@ -47,6 +51,9 @@ import {
getDecisionSignalMarketPhaseLabel,
getDecisionSignalSourceTypeLabel,
} from '../utils/decisionSignalLabels';
import { getDecisionProfile } from '../utils/decisionSignalProfile';
import { parseDecisionSignalDate } from '../utils/decisionSignalTime';
import { areStockCodesEquivalent } from '../utils/stockCode';
const PAGE_SIZE = 20;
const TIMELINE_PAGE_SIZE = 100;
@@ -103,7 +110,7 @@ type PendingStatusChange = {
type SelectedSignal = {
item: DecisionSignalItem;
source: 'list' | 'latest' | 'timeline';
source: 'list' | 'latest' | 'timeline' | 'persisted';
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -294,6 +301,39 @@ function toTimelineParams(filters: TimelineFilters, stockCode: string): Decision
};
}
function upsertDecisionSignal(
current: DecisionSignalItem[],
item: DecisionSignalItem,
limit?: number,
): DecisionSignalItem[] {
const next = [item, ...current.filter((candidate) => candidate.id !== item.id)];
next.sort((left, right) => {
const leftTime = parseDecisionSignalDate(left.createdAt)?.getTime() ?? Number.NEGATIVE_INFINITY;
const rightTime = parseDecisionSignalDate(right.createdAt)?.getTime() ?? Number.NEGATIVE_INFINITY;
return rightTime - leftTime || right.id - left.id;
});
return limit ? next.slice(0, limit) : next;
}
function itemMatchesStockContext(item: DecisionSignalItem, context: StockContext): boolean {
return areStockCodesEquivalent(item.stockCode, context.code)
&& (!context.market || item.market === context.market);
}
function itemMatchesAppliedTimeline(
item: DecisionSignalItem,
context: AppliedTimelineContext,
now = Date.now(),
): boolean {
if (!areStockCodesEquivalent(item.stockCode, context.stockCode)) return false;
if (context.market && item.market !== context.market) return false;
if (context.status === 'active' && item.status !== 'active') return false;
if (context.decisionProfile && getDecisionProfile(item) !== context.decisionProfile) return false;
const createdAt = parseDecisionSignalDate(item.createdAt)?.getTime();
if (createdAt === undefined) return false;
return createdAt >= now - TIMELINE_RANGE_DAYS[context.range] * DAY_MS && createdAt <= now;
}
function isSameStockContext(
previousContext: StockContext | null,
nextContext: StockContext,
@@ -386,6 +426,9 @@ const DecisionSignalsPage: React.FC = () => {
const [reassessProfile, setReassessProfile] = useState<DecisionProfile>('balanced');
const [reassessResponse, setReassessResponse] = useState<DecisionSignalReassessResponse | null>(null);
const [reassessLoading, setReassessLoading] = useState(false);
const [reassessPersisting, setReassessPersisting] = useState(false);
const [reassessPersistConfirm, setReassessPersistConfirm] = useState(false);
const [reassessPersistBlocked, setReassessPersistBlocked] = useState<DecisionSignalReassessBlockedError | null>(null);
const [reassessError, setReassessError] = useState<ParsedApiError | null>(null);
const requestIdRef = useRef(0);
const statsRequestIdRef = useRef(0);
@@ -578,7 +621,6 @@ const DecisionSignalsPage: React.FC = () => {
const selectedSourceReportId = selected?.item.sourceReportId ?? undefined;
const reassessSourceReportId = selected ? selectedSourceReportId : appliedSourceReportId;
const reassessContextKey = [
selected ? `selected:${selected.item.id}` : 'source',
reassessSourceReportId ?? '',
reassessProfile,
].join(':');
@@ -588,6 +630,9 @@ const DecisionSignalsPage: React.FC = () => {
setReassessResponse(null);
setReassessError(null);
setReassessLoading(false);
setReassessPersisting(false);
setReassessPersistConfirm(false);
setReassessPersistBlocked(null);
}, [reassessContextKey]);
const handleReassess = useCallback(async () => {
@@ -596,6 +641,7 @@ const DecisionSignalsPage: React.FC = () => {
reassessRequestIdRef.current = requestId;
setReassessLoading(true);
setReassessError(null);
setReassessPersistBlocked(null);
try {
const response = await decisionSignalsApi.reassess({
sourceReportId: reassessSourceReportId,
@@ -710,6 +756,89 @@ const DecisionSignalsPage: React.FC = () => {
}
}, []);
const handlePersistReassess = useCallback(async () => {
const preview = reassessResponse?.preview;
const guardrail = preview && isRecord(preview.metadata.guardrail_result)
? preview.metadata.guardrail_result
: null;
if (!reassessSourceReportId || !preview || guardrail?.passed !== true) return;
const requestId = reassessRequestIdRef.current + 1;
reassessRequestIdRef.current = requestId;
setReassessPersistConfirm(false);
setReassessPersisting(true);
setReassessError(null);
setReassessPersistBlocked(null);
try {
const response = await decisionSignalsApi.reassess({
sourceReportId: reassessSourceReportId,
decisionProfile: reassessProfile,
persist: true,
});
if (reassessRequestIdRef.current !== requestId) return;
if (!response.item || !response.persistStatus) {
throw new Error('DecisionSignal reassess persist response item and persist_status are required');
}
const authoritativeItem = response.item;
const shouldOptimisticallyUpsert = response.persistStatus !== 'existing';
setReassessResponse(response);
setSelected((current) => (
current
? { source: 'persisted', item: authoritativeItem }
: null
));
if (
shouldOptimisticallyUpsert
&&
activeStockContext
&& authoritativeItem.status === 'active'
&& itemMatchesStockContext(authoritativeItem, activeStockContext)
) {
setLatestItems((current) => upsertDecisionSignal(current, authoritativeItem, 5));
void loadLatestForContext(activeStockContext);
}
if (
shouldOptimisticallyUpsert
&&
appliedTimelineContext
&& itemMatchesAppliedTimeline(authoritativeItem, appliedTimelineContext)
) {
setTimelineItems((current) => upsertDecisionSignal(current, authoritativeItem));
void loadTimelineForContext(
{
code: appliedTimelineContext.stockCode,
market: appliedTimelineContext.market || undefined,
},
appliedTimelineContext,
);
}
void loadSignalsForPage(page);
} catch (err) {
if (reassessRequestIdRef.current !== requestId) return;
const blocked = getDecisionSignalReassessBlockedError(err);
if (blocked) {
setReassessPersistBlocked(blocked);
setReassessError(null);
} else {
setReassessError(getParsedApiError(err));
}
} finally {
if (reassessRequestIdRef.current === requestId) {
setReassessPersisting(false);
}
}
}, [
activeStockContext,
appliedTimelineContext,
loadLatestForContext,
loadSignalsForPage,
loadTimelineForContext,
page,
reassessProfile,
reassessResponse,
reassessSourceReportId,
]);
const applyStockContext = useCallback((nextContext: StockContext) => {
const nextTimeline = buildNextTimelineFilters(
timelineFilters,
@@ -795,6 +924,9 @@ const DecisionSignalsPage: React.FC = () => {
? null
: { source: 'timeline', item: updated };
}
if (current.source === 'persisted') {
return { source: 'persisted', item: updated };
}
if (!parseSourceReportId(appliedFilters.sourceReportId) && appliedFilters.status && updated.status !== appliedFilters.status) return null;
return { source: 'list', item: updated };
});
@@ -832,6 +964,28 @@ const DecisionSignalsPage: React.FC = () => {
const renderReassessPanel = () => {
const preview = reassessResponse?.preview ?? null;
const persistedItem = reassessResponse?.item ?? null;
const persistStatus = reassessResponse?.persistStatus ?? null;
const terminalExisting = persistStatus === 'existing' && persistedItem?.status !== 'active';
const persistedAlertVariant = terminalExisting
? 'warning'
: persistStatus === 'existing'
? 'info'
: 'success';
const persistedTitleKey: UiTextKey = terminalExisting
? 'decisionSignals.reassessPersistedTerminalTitle'
: persistStatus === 'existing'
? 'decisionSignals.reassessPersistedExistingTitle'
: persistStatus === 'refreshed'
? 'decisionSignals.reassessPersistedRefreshedTitle'
: 'decisionSignals.reassessPersistedCreatedTitle';
const persistedMessageKey: UiTextKey = terminalExisting
? 'decisionSignals.reassessPersistedTerminalExisting'
: persistStatus === 'existing'
? 'decisionSignals.reassessPersistedExisting'
: persistStatus === 'refreshed'
? 'decisionSignals.reassessPersistedRefreshed'
: 'decisionSignals.reassessPersistedCreated';
const metadata = preview?.metadata ?? {};
const guardrail = isRecord(metadata.guardrail_result) ? metadata.guardrail_result : null;
const rawAction = typeof guardrail?.raw_action === 'string' ? guardrail.raw_action : null;
@@ -857,7 +1011,7 @@ const DecisionSignalsPage: React.FC = () => {
value={reassessProfile}
onChange={(event) => setReassessProfile(event.target.value as DecisionProfile)}
aria-label={t('decisionSignals.reassessProfile')}
disabled={!reassessSourceReportId || reassessLoading}
disabled={!reassessSourceReportId || reassessLoading || reassessPersisting}
>
{REASSESS_PROFILES.map((profile) => (
<option key={profile} value={profile}>
@@ -869,7 +1023,7 @@ const DecisionSignalsPage: React.FC = () => {
type="button"
className="btn-secondary inline-flex h-10 items-center justify-center gap-2"
onClick={() => void handleReassess()}
disabled={!reassessSourceReportId || reassessLoading}
disabled={!reassessSourceReportId || reassessLoading || reassessPersisting}
>
<RefreshCw className={cn('h-4 w-4', reassessLoading ? 'animate-spin' : '')} />
{t('decisionSignals.reassessPreview')}
@@ -886,6 +1040,36 @@ const DecisionSignalsPage: React.FC = () => {
/>
) : null}
{reassessError ? <ApiErrorAlert className="mt-3" error={reassessError} /> : null}
{reassessPersistBlocked ? (
<div className="mt-3 space-y-2">
<InlineAlert
variant="danger"
title={t('decisionSignals.reassessPersistBlockedTitle')}
message={reassessPersistBlocked.blockedReason}
/>
{reassessPersistBlocked.warnings.length ? (
<ul className="list-disc space-y-1 pl-5 text-sm text-secondary-text">
{reassessPersistBlocked.warnings.map((warning, index) => (
<li key={`${warning.code}-${index}`}>{warning.message || warning.code}</li>
))}
</ul>
) : null}
</div>
) : null}
{persistedItem ? (
<InlineAlert
className="mt-3"
variant={persistedAlertVariant}
title={t(persistedTitleKey)}
message={t(
persistedMessageKey,
{
id: persistedItem.id,
status: t(STATUS_LABEL_KEYS[persistedItem.status]),
},
)}
/>
) : null}
{preview ? (
<div className="mt-4 space-y-3">
{reassessResponse?.blockedReason ? (
@@ -954,6 +1138,31 @@ const DecisionSignalsPage: React.FC = () => {
</ul>
</div>
) : null}
{passed === true ? (
<div className="flex justify-end">
<button
type="button"
className="btn-primary inline-flex h-10 items-center justify-center gap-2"
onClick={() => setReassessPersistConfirm(true)}
disabled={reassessLoading || reassessPersisting}
>
<ShieldCheck className="h-4 w-4" />
{reassessPersisting
? t('decisionSignals.reassessPersisting')
: t('decisionSignals.reassessPersist')}
</button>
</div>
) : null}
</div>
) : null}
{persistedItem && reassessResponse?.warnings.length ? (
<div className="mt-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-warning">{t('decisionSignals.reassessWarnings')}</p>
<ul className="mt-2 list-disc space-y-1 pl-4 text-sm text-secondary-text">
{reassessResponse.warnings.map((warning, index) => (
<li key={`${warning.code}-${index}`}>{warning.message || warning.code}</li>
))}
</ul>
</div>
) : null}
</div>
@@ -1390,6 +1599,17 @@ const DecisionSignalsPage: React.FC = () => {
/>
) : null}
<ConfirmDialog
isOpen={reassessPersistConfirm}
title={t('decisionSignals.reassessPersistConfirmTitle')}
message={t('decisionSignals.reassessPersistConfirmMessage')}
confirmText={t('decisionSignals.reassessPersist')}
confirmDisabled={reassessPersisting}
cancelDisabled={reassessPersisting}
onConfirm={() => void handlePersistReassess()}
onCancel={() => setReassessPersistConfirm(false)}
/>
<ConfirmDialog
isOpen={Boolean(pendingStatus)}
title={t('decisionSignals.confirmStatusTitle')}

View File

@@ -1,7 +1,10 @@
import type React from 'react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { decisionSignalsApi } from '../../api/decisionSignals';
import {
decisionSignalsApi,
getDecisionSignalReassessBlockedError,
} from '../../api/decisionSignals';
import { historyApi } from '../../api/history';
import { UiLanguageProvider } from '../../contexts/UiLanguageContext';
import type { StockBarResponse } from '../../types/analysis';
@@ -25,6 +28,7 @@ let stockIndexState: {
};
vi.mock('../../api/decisionSignals', () => ({
getDecisionSignalReassessBlockedError: vi.fn(),
decisionSignalsApi: {
list: vi.fn(),
getLatest: vi.fn(),
@@ -261,6 +265,65 @@ const reassessResponse: DecisionSignalReassessResponse = {
blockedReason: 'actionable_signal_blocked_by_guardrail',
};
const persistableReassessResponse: DecisionSignalReassessResponse = {
preview: {
action: 'watch',
score: 72,
confidence: null,
horizon: '3d',
entryLow: 1680,
stopLoss: 1600,
reason: 'persistable preview reason',
metadata: {
decision_profile: 'balanced',
guardrail_result: {
raw_action: 'buy',
final_action: 'watch',
passed: true,
violations: ['missing_confidence'],
adjustments: ['action_downgraded_by_guardrail'],
adjusted: true,
},
},
},
item: null,
created: false,
warnings: [{ code: 'action_adjusted_by_guardrail', message: '已由风控调整为 watch。' }],
blockedReason: null,
};
const persistedReassessItem = makeSignal({
id: 88,
decisionProfile: 'balanced',
sourceAgent: 'decision_profile_reassess',
triggerSource: 'web:decision_profile_reassess',
action: 'watch',
actionLabel: '观望',
confidence: null,
createdAt: new Date(Date.now() - 1000).toISOString(),
updatedAt: new Date(Date.now() - 1000).toISOString(),
metadata: {
decision_profile: 'balanced',
guardrail_result: {
raw_action: 'buy',
final_action: 'watch',
passed: true,
violations: ['missing_confidence'],
adjustments: ['action_downgraded_by_guardrail'],
adjusted: true,
},
},
});
const persistedReassessResponse: DecisionSignalReassessResponse = {
preview: null,
item: persistedReassessItem,
created: true,
persistStatus: 'created',
warnings: [{ code: 'action_adjusted_by_guardrail', message: '已由风控调整为 watch。' }],
blockedReason: null,
};
function renderPage() {
return render(
<UiLanguageProvider>
@@ -283,6 +346,15 @@ function submitCurrentStock(value: string) {
fireEvent.click(screen.getByRole('button', { name: '查看股票' }));
}
async function persistReassessFromFirstSignal() {
await screen.findByText('贵州茅台');
fireEvent.click(screen.getAllByRole('button', { name: '查看 贵州茅台 AI 建议详情' })[0]);
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '生成预览' }));
fireEvent.click(await screen.findByRole('button', { name: '确认保存' }));
const confirmButtons = screen.getAllByRole('button', { name: '确认保存' });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
}
beforeEach(() => {
window.history.pushState({}, '', '/');
window.localStorage.clear();
@@ -308,6 +380,7 @@ beforeEach(() => {
});
vi.mocked(decisionSignalsApi.updateStatus).mockResolvedValue({ ...signal, status: 'invalidated' });
vi.mocked(decisionSignalsApi.reassess).mockResolvedValue(reassessResponse);
vi.mocked(getDecisionSignalReassessBlockedError).mockReturnValue(null);
});
describe('DecisionSignalsPage', () => {
@@ -495,6 +568,346 @@ describe('DecisionSignalsPage', () => {
expect(decisionSignalsApi.list).not.toHaveBeenCalled();
});
it('confirms persist, trusts the returned item, and refreshes list and active timeline state', async () => {
let persisted = false;
vi.mocked(decisionSignalsApi.reassess).mockImplementation(async (request) => {
if (!request.persist) return persistableReassessResponse;
persisted = true;
return persistedReassessResponse;
});
vi.mocked(decisionSignalsApi.list).mockImplementation(async () => (
listResponse(persisted ? [persistedReassessItem, signal] : [signal])
));
vi.mocked(decisionSignalsApi.getLatest).mockImplementation(async () => (
listResponse(persisted ? [persistedReassessItem, signal] : [signal])
));
renderPage();
await screen.findByText('贵州茅台');
fireEvent.click(screen.getByRole('button', { name: '查看 贵州茅台 AI 建议详情' }));
submitCurrentStock('600519');
await waitFor(() => {
expect(decisionSignalsApi.list).toHaveBeenCalledWith(expect.objectContaining({
stockCode: '600519',
pageSize: 100,
}));
});
fireEvent.click(screen.getByRole('button', { name: '生成预览' }));
const saveButton = await screen.findByRole('button', { name: '确认保存' });
fireEvent.click(saveButton);
expect(screen.getByText('保存重评估信号')).toBeInTheDocument();
const confirmButtons = screen.getAllByRole('button', { name: '确认保存' });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(decisionSignalsApi.reassess).toHaveBeenLastCalledWith({
sourceReportId: 3001,
decisionProfile: 'balanced',
persist: true,
});
});
expect(await screen.findByText('已保存为新的 DecisionSignal #88。')).toBeInTheDocument();
expect(screen.getByText('已由风控调整为 watch。')).toBeInTheDocument();
expect(await screen.findByTestId('timeline-click-88')).toBeInTheDocument();
await waitFor(() => expect(
vi.mocked(decisionSignalsApi.list).mock.calls.filter(([params]) => params?.pageSize === 100),
).toHaveLength(2));
await waitFor(() => expect(decisionSignalsApi.getLatest).toHaveBeenCalledTimes(2));
await waitFor(() => expect(decisionSignalsApi.list).toHaveBeenCalledWith(expect.objectContaining({
status: 'active',
page: 1,
pageSize: 20,
})));
});
it('keeps a newly persisted terminal history item out of latest active while retaining it in the timeline', async () => {
const terminalHistoryItem = makeSignal({
id: 92,
decisionProfile: 'balanced',
sourceAgent: 'decision_profile_reassess',
triggerSource: 'web:decision_profile_reassess',
action: 'buy',
status: 'invalidated',
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
});
let persisted = false;
vi.mocked(decisionSignalsApi.reassess).mockImplementation(async (request) => {
if (!request.persist) return persistableReassessResponse;
persisted = true;
return {
preview: null,
item: terminalHistoryItem,
created: true,
persistStatus: 'created',
warnings: [],
blockedReason: null,
};
});
vi.mocked(decisionSignalsApi.list).mockImplementation(async () => (
listResponse(persisted ? [terminalHistoryItem, signal] : [signal])
));
vi.mocked(decisionSignalsApi.getLatest).mockResolvedValue(listResponse([signal]));
renderPage();
await screen.findByText('贵州茅台');
submitCurrentStock('600519');
await persistReassessFromFirstSignal();
expect(await screen.findByText('已保存为新的 DecisionSignal #92。')).toBeInTheDocument();
expect(await screen.findByTestId('timeline-click-92')).toBeInTheDocument();
await waitFor(() => expect(
vi.mocked(decisionSignalsApi.list).mock.calls.filter(([params]) => params?.pageSize === 100),
).toHaveLength(2));
expect(decisionSignalsApi.getLatest).toHaveBeenCalledTimes(1);
});
it('reports an auto-balanced exact match as existing without claiming a new save', async () => {
const autoBalancedItem = makeSignal({
id: 89,
decisionProfile: 'balanced',
sourceAgent: null,
triggerSource: 'api',
action: 'buy',
metadata: {
decision_profile: 'balanced',
profile_source: 'auto_default',
signal_generation_version: 'legacy-report-extractor-v1',
},
});
vi.mocked(decisionSignalsApi.reassess).mockImplementation(async (request) => (
request.persist
? {
preview: null,
item: autoBalancedItem,
created: false,
persistStatus: 'existing',
warnings: [],
blockedReason: null,
}
: persistableReassessResponse
));
renderPage();
await persistReassessFromFirstSignal();
expect(await screen.findByText('已复用现有信号')).toBeInTheDocument();
expect(screen.getByText(/DecisionSignal #89 已存在,本次没有重复创建/)).toBeInTheDocument();
expect(screen.queryByText(/已保存为新的 DecisionSignal #89/)).not.toBeInTheDocument();
});
it('reports an expired signal refresh separately and refreshes active views', async () => {
const refreshedItem = makeSignal({
id: 90,
decisionProfile: 'balanced',
sourceAgent: null,
triggerSource: 'api',
action: 'buy',
status: 'active',
metadata: {
decision_profile: 'balanced',
profile_source: 'user_selected',
signal_generation_version: 'decision-profile-reassess-v1',
},
});
let persisted = false;
vi.mocked(decisionSignalsApi.reassess).mockImplementation(async (request) => {
if (!request.persist) return persistableReassessResponse;
persisted = true;
return {
preview: null,
item: refreshedItem,
created: false,
persistStatus: 'refreshed',
warnings: [],
blockedReason: null,
};
});
vi.mocked(decisionSignalsApi.list).mockImplementation(async () => (
listResponse(persisted ? [refreshedItem, signal] : [signal])
));
vi.mocked(decisionSignalsApi.getLatest).mockImplementation(async () => (
listResponse(persisted ? [refreshedItem, signal] : [signal])
));
renderPage();
await screen.findByText('贵州茅台');
submitCurrentStock('600519');
await persistReassessFromFirstSignal();
expect(await screen.findByText('重评估信号已刷新')).toBeInTheDocument();
expect(screen.getByText(/DecisionSignal #90 已按存储契约完成过期续期或缺失维度补齐/)).toBeInTheDocument();
expect(await screen.findByTestId('timeline-click-90')).toBeInTheDocument();
await waitFor(() => expect(decisionSignalsApi.getLatest).toHaveBeenCalledTimes(2));
});
it('keeps a terminal existing item terminal and does not inject it into active views', async () => {
const terminalItem = makeSignal({
id: 91,
decisionProfile: 'balanced',
sourceAgent: null,
triggerSource: 'api',
action: 'buy',
status: 'closed',
metadata: {
decision_profile: 'balanced',
profile_source: 'auto_default',
signal_generation_version: 'legacy-report-extractor-v1',
},
});
vi.mocked(decisionSignalsApi.reassess).mockImplementation(async (request) => (
request.persist
? {
preview: null,
item: terminalItem,
created: false,
persistStatus: 'existing',
warnings: [],
blockedReason: null,
}
: persistableReassessResponse
));
vi.mocked(decisionSignalsApi.getLatest).mockResolvedValue(listResponse([signal]));
vi.mocked(decisionSignalsApi.list).mockResolvedValue(listResponse([signal]));
renderPage();
await screen.findByText('贵州茅台');
submitCurrentStock('600519');
await persistReassessFromFirstSignal();
expect(await screen.findByText('现有信号保持终态')).toBeInTheDocument();
expect(screen.getByText(/DecisionSignal #91 已处于“已关闭”状态/)).toBeInTheDocument();
expect(screen.queryByTestId('timeline-click-91')).not.toBeInTheDocument();
expect(screen.queryByText(/已保存为新的 DecisionSignal #91/)).not.toBeInTheDocument();
await waitFor(() => expect(
vi.mocked(decisionSignalsApi.list).mock.calls.filter(([params]) => params?.pageSize === 20),
).toHaveLength(2));
expect(decisionSignalsApi.getLatest).toHaveBeenCalledTimes(1);
});
it('keeps the authoritative persist result visible after refreshing a latest-sourced detail', async () => {
const latestSignal = makeSignal({
id: 8,
stockCode: 'AAPL',
stockName: 'Apple',
market: 'us',
riskSummary: 'Latest reassess source',
});
const persistedLatestItem = {
...persistedReassessItem,
stockCode: 'AAPL',
stockName: 'Apple',
market: 'us' as const,
};
let persisted = false;
vi.mocked(decisionSignalsApi.reassess).mockImplementation(async (request) => {
if (!request.persist) return persistableReassessResponse;
persisted = true;
return { ...persistedReassessResponse, item: persistedLatestItem };
});
vi.mocked(decisionSignalsApi.getLatest).mockImplementation(async () => (
listResponse(persisted ? [persistedLatestItem, latestSignal] : [latestSignal])
));
vi.mocked(decisionSignalsApi.updateStatus).mockResolvedValueOnce({
...persistedLatestItem,
status: 'invalidated',
});
renderPage();
await screen.findByText('贵州茅台');
submitCurrentStock('AAPL');
fireEvent.click(await screen.findByRole('button', { name: '查看 Apple AI 建议详情' }));
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '生成预览' }));
fireEvent.click(await screen.findByRole('button', { name: '确认保存' }));
const confirmButtons = screen.getAllByRole('button', { name: '确认保存' });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => expect(decisionSignalsApi.getLatest).toHaveBeenCalledTimes(2));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('已保存为新的 DecisionSignal #88。')).toBeInTheDocument();
expect(within(dialog).getByText('观望')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '标记失效' }));
fireEvent.click(await screen.findByRole('button', { name: '确定' }));
await waitFor(() => expect(decisionSignalsApi.updateStatus).toHaveBeenCalledWith(88, { status: 'invalidated' }));
expect(within(screen.getByRole('dialog')).getByText('已保存为新的 DecisionSignal #88。')).toBeInTheDocument();
expect(within(screen.getByRole('dialog')).getByText('已失效')).toBeInTheDocument();
});
it('keeps the authoritative persist result visible after refreshing a timeline-sourced detail', async () => {
const timelineSignal = makeSignal({
id: 8,
stockCode: 'AAPL',
stockName: 'Apple',
market: 'us',
riskSummary: 'Timeline reassess source',
});
const persistedTimelineItem = {
...persistedReassessItem,
stockCode: 'AAPL',
stockName: 'Apple',
market: 'us' as const,
};
let persisted = false;
vi.mocked(decisionSignalsApi.reassess).mockImplementation(async (request) => {
if (!request.persist) return persistableReassessResponse;
persisted = true;
return { ...persistedReassessResponse, item: persistedTimelineItem };
});
vi.mocked(decisionSignalsApi.list).mockImplementation(async (params) => (
params?.pageSize === 100
? listResponse(persisted ? [persistedTimelineItem, timelineSignal] : [timelineSignal])
: listResponse()
));
renderPage();
await screen.findByText('贵州茅台');
submitCurrentStock('AAPL');
fireEvent.click(await screen.findByTestId('timeline-click-8'));
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '生成预览' }));
fireEvent.click(await screen.findByRole('button', { name: '确认保存' }));
const confirmButtons = screen.getAllByRole('button', { name: '确认保存' });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => expect(
vi.mocked(decisionSignalsApi.list).mock.calls.filter(([params]) => params?.pageSize === 100),
).toHaveLength(2));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('已保存为新的 DecisionSignal #88。')).toBeInTheDocument();
expect(within(dialog).getByText('观望')).toBeInTheDocument();
});
it('keeps the preview visible and renders structured persist guardrail errors', async () => {
const persistError = new Error('guardrail blocked');
vi.mocked(decisionSignalsApi.reassess)
.mockResolvedValueOnce(persistableReassessResponse)
.mockRejectedValueOnce(persistError);
vi.mocked(getDecisionSignalReassessBlockedError).mockImplementation((error) => (
error === persistError
? {
blockedReason: 'invalid_price_relationships',
warnings: [{ code: 'action_blocked_by_guardrail', message: '价格关系矛盾,未保存。' }],
}
: null
));
renderPage();
await screen.findByText('贵州茅台');
fireEvent.click(screen.getByRole('button', { name: '查看 贵州茅台 AI 建议详情' }));
fireEvent.click(screen.getByRole('button', { name: '生成预览' }));
fireEvent.click(await screen.findByRole('button', { name: '确认保存' }));
const confirmButtons = screen.getAllByRole('button', { name: '确认保存' });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
expect(await screen.findByText('保存被风控阻断')).toBeInTheDocument();
expect(screen.getByText('invalid_price_relationships')).toBeInTheDocument();
expect(screen.getByText('价格关系矛盾,未保存。')).toBeInTheDocument();
expect(screen.getByText('persistable preview reason')).toBeInTheDocument();
expect(screen.queryByText(/DecisionSignal #88/)).not.toBeInTheDocument();
});
it('disables reassess when no source report id is available', async () => {
vi.mocked(decisionSignalsApi.list).mockResolvedValueOnce(listResponse([
makeSignal({ sourceReportId: null }),
@@ -547,7 +960,7 @@ describe('DecisionSignalsPage', () => {
await act(async () => {
pending.resolve({
...reassessResponse,
preview: { ...reassessResponse.preview, reason: 'stale A preview' },
preview: { ...reassessResponse.preview!, reason: 'stale A preview' },
});
});

View File

@@ -135,7 +135,7 @@ export interface DecisionSignalWarning {
export interface DecisionSignalReassessRequest {
sourceReportId: number;
decisionProfile: DecisionProfile;
persist?: false;
persist?: boolean;
}
export interface DecisionSignalReassessPreview {
@@ -154,14 +154,22 @@ export interface DecisionSignalReassessPreview {
metadata: Record<string, unknown>;
}
export type DecisionSignalPersistStatus = 'created' | 'existing' | 'refreshed';
export interface DecisionSignalReassessResponse {
preview: DecisionSignalReassessPreview;
preview?: DecisionSignalReassessPreview | null;
item?: DecisionSignalItem | null;
created: false;
created: boolean;
persistStatus?: DecisionSignalPersistStatus | null;
warnings: DecisionSignalWarning[];
blockedReason?: string | null;
}
export interface DecisionSignalReassessBlockedError {
blockedReason: string;
warnings: DecisionSignalWarning[];
}
export interface DecisionSignalListResponse {
items: DecisionSignalItem[];
total: number;

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [改进] 通知推送与完整 Markdown/微信报告不再重复附加“AI 决策信号”摘要DecisionSignal 的存储、告警和 Web AI 建议页保持不变。
- [改进] TickFlow 新增基于申万一级行业池的行业涨跌排行 fallback并将基本面/市场结构单能力默认超时由 3 秒调整为 8 秒,降低正常慢响应被提前降级的概率。
- [文档] 补充 macOS 未签名、未公证 DMG 被 Gatekeeper 拦截时的架构选择、安全排查与官方安装包临时放行步骤。
- [新功能] Web AI 建议页支持确认保存基于历史报告快照重算的决策风格信号,以 created/existing/refreshed 区分新建、原样复用和既有记录续期或维度补齐,复用 profile-aware 去重与失效语义,将历史信号的创建时间、有效期和相反信号失效顺序锚定来源报告时间,并提供可审计 guardrail 提示与阻断。
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->

View File

@@ -2585,6 +2585,93 @@
}
}
}
},
"/api/v1/decision-signals/reassess": {
"post": {
"tags": [
"DecisionSignals"
],
"summary": "重评估决策风格并可选保存",
"description": "基于 source_report_id 对应的持久化历史报告快照重新计算 decision_profile 信号persist=false 返回只读 previewpersist=true 将通过 guardrail 的服务端结果写入 DecisionSignal。",
"operationId": "reassessDecisionSignalPreview",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DecisionSignalReassessRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DecisionSignalReassessResponse"
}
}
}
},
"401": {
"description": "未登录或管理员会话无效ADMIN_AUTH_ENABLED=true 时)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"400": {
"description": "历史报告不适用或持久化被风控阻断",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DecisionSignalReassessErrorResponse"
}
}
}
},
"404": {
"description": "来源历史报告不存在",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "请求体校验失败",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "重评估失败",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"AdminSessionCookie": []
}
]
}
}
},
"components": {
@@ -6864,6 +6951,318 @@
"type"
],
"title": "ValidationError"
},
"DecisionSignalReassessRequest": {
"properties": {
"source_report_id": {
"type": "integer",
"exclusiveMinimum": 0.0,
"title": "Source Report Id"
},
"decision_profile": {
"type": "string",
"enum": [
"conservative",
"balanced",
"aggressive"
],
"title": "Decision Profile"
},
"persist": {
"type": "boolean",
"title": "Persist",
"default": false
}
},
"additionalProperties": false,
"type": "object",
"required": [
"source_report_id",
"decision_profile"
],
"title": "DecisionSignalReassessRequest"
},
"DecisionSignalReassessResponse": {
"properties": {
"preview": {
"anyOf": [
{
"$ref": "#/components/schemas/DecisionSignalPreview"
},
{
"type": "null"
}
]
},
"item": {
"anyOf": [
{
"$ref": "#/components/schemas/DecisionSignalItem"
},
{
"type": "null"
}
]
},
"created": {
"type": "boolean",
"title": "Created",
"default": false
},
"persist_status": {
"anyOf": [
{
"type": "string",
"enum": [
"created",
"existing",
"refreshed"
]
},
{
"type": "null"
}
],
"title": "Persist Status"
},
"warnings": {
"items": {
"$ref": "#/components/schemas/DecisionSignalWarning"
},
"type": "array",
"title": "Warnings"
},
"blocked_reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Blocked Reason"
}
},
"type": "object",
"title": "DecisionSignalReassessResponse"
},
"DecisionSignalReassessErrorResponse": {
"properties": {
"error": {
"type": "string",
"enum": [
"unsupported_report_type",
"unsupported_report_snapshot",
"guardrail_blocked"
],
"title": "Error"
},
"message": {
"type": "string",
"title": "Message"
},
"blocked_reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Blocked Reason"
},
"warnings": {
"items": {
"$ref": "#/components/schemas/DecisionSignalWarning"
},
"type": "array",
"title": "Warnings"
}
},
"type": "object",
"required": [
"error",
"message"
],
"title": "DecisionSignalReassessErrorResponse"
},
"DecisionSignalWarning": {
"properties": {
"code": {
"type": "string",
"title": "Code"
},
"message": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Message"
},
"params": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Params"
}
},
"type": "object",
"required": [
"code"
],
"title": "DecisionSignalWarning"
},
"DecisionSignalPreview": {
"properties": {
"action": {
"type": "string",
"title": "Action"
},
"score": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Score"
},
"confidence": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Confidence"
},
"horizon": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Horizon"
},
"entry_low": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Entry Low"
},
"entry_high": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Entry High"
},
"stop_loss": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Stop Loss"
},
"target_price": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Target Price"
},
"invalidation": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Invalidation"
},
"reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Reason"
},
"risk_summary": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Risk Summary"
},
"watch_conditions": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Watch Conditions"
},
"metadata": {
"additionalProperties": true,
"type": "object",
"title": "Metadata"
}
},
"type": "object",
"required": [
"action",
"metadata"
],
"title": "DecisionSignalPreview"
}
},
"securitySchemes": {

View File

@@ -6,7 +6,7 @@
- `DecisionSignal` 只记录建议、证据摘要、风险、观察条件、生命周期和来源,不执行下单或调仓。
- 写入失败、提取失败、告警信号关联失败和通知发送失败都不阻断主分析、告警触发或报告保存。
- #1756 `decision_profile` 字段化并修正 server-side filter、去重、续期和 active 失效语义不新增环境变量、config registry 项或 `.env.example` 内容。
- #1756 `decision_profile` 字段化并修正 server-side filter、去重、续期和 active 失效语义;#1757 在该正式字段契约上增加用户确认后的 reassess persist。两者都不新增环境变量、config registry 项或 `.env.example` 内容。
- 当前没有 `DECISION_SIGNAL_*` 开关;信号功能的关闭或回滚通过 revert 对应代码完成。
## 字段与枚举
@@ -75,13 +75,13 @@ Web 展示必须把这些 wire value 映射为当前 UI 语言的用户可读标
- `POST /api/v1/decision-signals/outcomes/run`:显式触发后验评估。
- `GET /api/v1/decision-signals/outcomes``GET /api/v1/decision-signals/outcomes/stats``GET /api/v1/decision-signals/{signal_id}/outcomes`:查询后验结果与统计。
- `GET/PUT /api/v1/decision-signals/{signal_id}/feedback`:查询或写入 useful / not useful 反馈。
- `POST /api/v1/decision-signals/reassess`:基于来源历史报告预览不同决策风格下的信号,不写库
- `POST /api/v1/decision-signals/reassess`:基于来源历史报告快照重新计算不同决策风格下的信号`persist=false` 只预览,`persist=true` 由服务端重算并保存通过 guardrail 的结果
这些接口继承现有 `/api/v1/*` 管理员鉴权;`ADMIN_AUTH_ENABLED=true` 时需要有效管理员会话 Cookie。
## Reassess preview
## Reassess preview 与 persist
`reassess` 第一版只做 preview不创建或更新 `DecisionSignal`
`reassess` 只使用 `source_report_id` 对应的持久化历史报告快照。`persist=false` 用于用户确认前预览;`persist=true` 会以相同 `source_report_id + decision_profile` 在服务端重新计算,不信任之前 preview 或客户端缓存的任何决策字段
请求只支持:
@@ -96,13 +96,23 @@ Web 展示必须把这些 wire value 映射为当前 UI 语言的用户可读标
契约边界:
- `source_report_id` 是唯一事实来源,重评估只读取对应持久化历史报告快照。
- 不支持 `signal_id`,也不接受客户端提交 `action``score``confidence`、价格、metadata 或 guardrail 结果;额外字段会被请求校验拒绝
- `persist=true` 当前固定返回 HTTP 400错误码为 `unsupported_operation`。保存重评估结果留给 #1757
- Request 只允许 `source_report_id``decision_profile``persist`。不支持 `signal_id`,也不接受客户端提交 `action``score``confidence``horizon``invalidation``stop_loss``target_price``metadata``scoring_breakdown``guardrail_result` 等权威字段;额外字段会返回 HTTP 422不会被静默忽略
- 重评估不会静默抓取实时行情,也不会用当前市场数据补齐历史快照。
- 历史报告不存在、非个股报告或快照缺少结构化决策输入时,分别返回明确错误
- 来源报告内容验证在 preview/persist 中一致:缺失或非法 `source_report_id` 返回 HTTP 422报告不存在返回 HTTP 404 `source_report_not_found`;非个股报告返回 HTTP 400 `unsupported_report_type`;持久化快照不足以生成决策信号时返回 HTTP 400 `unsupported_report_snapshot`。Persist 还要求来源报告具有有效 `created_at`,否则返回 HTTP 400 `unsupported_report_snapshot` 且不写库preview 不依赖该存储生命周期字段
- data quality 会归一为 `high``medium``low``poor``unknown`guardrail 只使用归一化后的等级。
- `guardrail_result` 是机器审计数据,记录 raw/final action、是否通过、violations 和 adjustments`warnings` 是用户可读摘要,测试和客户端逻辑应优先依赖稳定 `code`
- blocked preview 仍是 HTTP 200UI 必须突出 `blocked_reason`,不能把它当作普通可执行信号
- Preview 成功返回 `preview``item=null``created=false`它不写库也不进入列表、latest 或时间线
- Persist 成功返回 `preview=null`、后端权威 `item``persist_status``persist_status=created` 表示新建;`existing` 表示同一字段化 identity 的记录已存在且未被改写;`refreshed` 表示按既有 expired refresh / dimension-fill 语义复用并刷新记录。兼容字段 `created` 只在 `created` 时为 `true``persist_status` 只描述本次写入 disposition不代表 `item.status` 必然为 active新建的历史信号也可能因到期或被较新相反信号取代而以 `expired/invalidated` 返回
- Reassess persist 与 lazy backfill 使用同一历史生命周期:`created_at` 锚定来源报告时间,`expires_at` 从报告时间、horizon、market 及持久化的 `market_phase_summary` 计算。阶段摘要只保留 `phase/session_date/minutes_to_open/minutes_to_close`;不会用保存当天或实时行情重新赋予有效期。
- 同 profile 相反信号的失效顺序同样按历史信号不可变的 `created_at` 判断expired refresh 的 `updated_at` 不改变历史优先级。保存旧报告不得淘汰较新的相反信号;仍在有效期内但已被较新相反信号取代的历史 item 会以 `invalidated` 返回,且 API 返回失效处理后的最终数据库状态。
- `created` item 写入 `source_type=analysis`、原 `source_report_id``source_agent=decision_profile_reassess``trigger_source=web:decision_profile_reassess` 和正式 `decision_profile`metadata 保存 `profile_source=user_selected``profile_policy_version``signal_generation_version``scoring_version``scoring_breakdown``data_quality_level` 和完整 `guardrail_result`
- `existing` item 原样保留最初的 source fields 和 metadata。例如普通分析已经自动生成同 identity 的 `balanced/auto_default` 信号时,用户再次确认 balanced reassess 会返回该记录,不覆盖为 `user_selected`,也不会声称新建成功。终态 existing 不会重新激活。
- `refreshed` item 保留不可变的原始创建 provenance`source_type``source_report_id``source_agent``trigger_source``created_at` 等),并沿用 #1756 repository 的两个既有子语义expired refresh 会更新允许变化的决策字段、有效期和本次 reassess audit metadataactive relaxed dimension-fill 只补齐缺失的 horizon/market phase保留原 metadata。客户端必须以后端返回 item 为准,不能仅凭 `refreshed` 推断 metadata 已被替换。
- `guardrail_result` 是机器审计数据,记录 `raw_action``final_action``passed``violations``adjustments``adjusted``warnings` 是用户可读摘要。测试和客户端逻辑应优先依赖 warning 的稳定 `code``message` 只用于首版展示。
- `MIN_ACTIONABLE_CONFIDENCE = 0.5`。所有 `buy/add` 还必须具备 horizon、invalidation 或 stop loss、合法价格关系且 data quality 不能是 `poor/unknown`aggressive `buy/add` 额外要求明确 invalidation且不接受 `long` horizon。
- 缺失置信度/invalidation 或数据质量不足时,可审计地降级为 `watch`,并记录 `passed=true, adjusted=true`。价格关系互相矛盾时无法在不改写历史快照语义的前提下保存有效计划,因此记录 `passed=false`
- Preview-only 的 `passed=false` 仍以 HTTP 200 展示UI 必须突出 `blocked_reason`。Persist 重算得到 `passed=false` 时返回 HTTP 400 `guardrail_blocked`,包含 `blocked_reason` 和结构化 `warnings`,不写库,也不返回 `created=true`
- 每次 persist 重算都必须先满足 `guardrail_result.passed=true` 才能进入写入链;`created/refreshed``item.action` 等于本次 `guardrail_result.final_action``existing` 返回原记录及其原始 metadata不伪造本次 guardrail audit。
- 默认分析和 lazy backfill 仍只自动生成 `balanced`;用户可显式选择并确认保存 balanced、conservative 或 aggressive其中 conservative/aggressive 不会自动生成。
- aggressive 不是模型采样温度语义,也不会自动生成三套 profile 信号。
## Web 展示
@@ -123,7 +133,9 @@ Web 入口位于 `/decision-signals`
- Web 展示优先读取正式 `decision_profile` 字段,只有字段缺失时才回退 legacy metadata历史缺失或非法 profile 的信号显示为 `unknown`,不会误标为 `balanced`
- market filter 在 API / 服务层与 Web 前端均已支持 `cn/hk/us/jp/kr/tw``jp/kr/tw` 的前端本地化标签均已补齐,`tw` 信号可经 API 正常写入、按 `market=tw` 查询,并可在 Web DecisionSignal 页面通过市场筛选项选择台股tw告警大盘红绿灯市场支持 `cn/hk/us/jp/kr`
- 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。
- 详情抽屉或已有来源报告 ID 的页面上下文可以发起 reassess preview没有可用来源报告 ID 时入口禁用。Preview 不加入列表、latest 或时间线,也不提供保存按钮
- 详情抽屉或已有来源报告 ID 的页面上下文可以发起 reassess preview没有可用来源报告 ID 时入口禁用。Preview 本身不加入列表、latest 或时间线;通过 guardrail 后可由用户二次确认保存。保存会重新请求 `persist=true`,成功后只使用响应中的后端 `item``created``existing``refreshed` 使用不同反馈existing 不会被描述为新建,终态 existing 不会被乐观注入 active latest/时间线created/refreshed 才按返回状态更新并刷新相关视图。Web 不会把 preview 拼成本地信号
- 保存时的 guardrail 调整 warning 会保留显示。如果 persist 重算被 guardrail 阻断Web 会显示 `blocked_reason` 和结构化 warning保留 preview 供用户理解,且不会把失败结果加入时间线。
- 首页分析表单不提供 `decision_profile`;默认自动生成路径仍只使用 `balanced`
- Web 只能把信号标记为 `closed``invalidated``archived`,不提供 terminal 状态恢复为 active。
- 历史报告详情不再内嵌展示报告绑定的 `source_type=analysis` 信号,也不会因打开报告详情触发 `source_report_id` 信号查询;需要查看报告来源信号时统一进入 `/decision-signals` 页面按来源报告 ID 精确筛选,或打开 `/decision-signals?sourceReportId=<recordId>` deep link。该筛选和 deep link 都会使用 `source_type=analysis + source_report_id` 的精确查询,以保留旧报告的 best-effort 懒回填入口。
- 持仓页异步查询每个唯一持仓的 latest active 信号,单只查询失败只显示降级提示,不阻断组合快照或其他持仓信号。
@@ -145,8 +157,8 @@ Web 入口位于 `/decision-signals`
- 新写入会同步 `metadata.decision_profile` 为正式字段值避免双源冲突metadata 省略或显式 `null` 均按无 metadata 处理object 会浅复制,非 object 会被拒绝。
- PATCH metadata 省略时保留原值,显式 `null` 时清空为 SQL `NULL`object 时整包替换。正式 profile 非 `NULL` 时会覆盖 metadata 中的冲突值;正式 profile 为 legacy `NULL` 时会移除请求 object 中的 profile key且不会提升正式字段。
- 自动失效写入同样遵循正式字段权威语义:正式 profile 非 `NULL` 时同步 metadata profilelegacy `NULL` 时只追加失效信息,保留原 legacy metadata不注入或删除 profile。
- Legacy / unknown 只用数据库 `NULL` 表示。`profile_policy_version` 只表示默认 profile metadata contract version不代表已经实现独立 profile policy engine、scoring engine 或多 profile 生成。P1/P2 不写入 `scoring_version``scoring_breakdown`;这些字段如需引入,应由后续 reassess / scoring issue 定义
- Lazy backfill 语义:省略 profile 保留旧的 `source_type=analysis + source_report_id` 懒回填;`decision_profile=balanced` 可生成 balanced 回填;`decision_profile=unknown``conservative``aggressive` 不自动创建行。
- Legacy / unknown 只用数据库 `NULL` 表示。普通自动生成与 lazy backfill 不写入 `scoring_version``scoring_breakdown`;只有用户显式发起的 reassess 路径根据 profile policy 生成并审计这些字段。这不代表自动生成三套 profile也不包含 #1758 的 profile-aware outcome calibration
- Lazy backfill 语义:省略 profile 保留旧的 `source_type=analysis + source_report_id` 懒回填;`decision_profile=balanced` 可生成 balanced 回填;`decision_profile=unknown``conservative``aggressive` 不自动创建行。回填与 reassess persist 共享来源报告时间、历史 TTL 和 superseded 判断,不存在第二套历史生命周期。
## 市场结构 metadata

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Minimal deterministic decision-profile policy for reassess preview."""
"""Minimal deterministic decision-profile policy for reassessment."""
from __future__ import annotations
@@ -76,7 +76,7 @@ def apply_decision_profile_policy(
decision_profile: str,
data_quality_level: DecisionSignalDataQuality,
) -> PolicyResult:
"""Apply the P3a minimal profile policy and guardrail to a snapshot candidate."""
"""Apply the minimal profile policy and guardrail to a snapshot candidate."""
normalized_candidate = _apply_profile_bias(candidate, decision_profile)
guardrail = _apply_guardrail(normalized_candidate, decision_profile, data_quality_level)
@@ -158,7 +158,11 @@ def _apply_guardrail(
has_price_violation = any(code in PRICE_RELATIONSHIP_VIOLATION_CODES for code in violations)
final_action = "alert" if has_price_violation else "watch"
adjustments.append("action_downgraded_by_guardrail")
passed = False
# Missing confidence/invalidation or weak data can safely become a
# non-actionable watch signal. Contradictory price relationships cannot
# be persisted without changing the historical snapshot, so they stay
# blocked even though preview still exposes an alert fallback.
passed = not has_price_violation
adjusted = raw_action != final_action or bool(adjustments)
return GuardrailResult(
@@ -224,6 +228,10 @@ def _warnings_for_guardrail(guardrail: GuardrailResult) -> list[dict[str, object
warnings.append(
{
"code": "action_adjusted_by_guardrail",
"message": (
f"原始动作 {guardrail.raw_action} 已由风控调整为 "
f"{guardrail.final_action}"
),
"params": {
"raw_action": guardrail.raw_action,
"final_action": guardrail.final_action,
@@ -234,6 +242,7 @@ def _warnings_for_guardrail(guardrail: GuardrailResult) -> list[dict[str, object
warnings.append(
{
"code": "action_blocked_by_guardrail",
"message": "重评估结果未通过持久化风控,未保存为决策信号。",
"params": {
"raw_action": guardrail.raw_action,
"final_action": guardrail.final_action,

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Preview-only decision-profile reassessment from persisted analysis history."""
"""Decision-profile reassessment from persisted analysis history."""
from __future__ import annotations
@@ -18,16 +18,12 @@ from src.services.decision_profile_policy import (
apply_decision_profile_policy,
)
from src.services.decision_signal_data_quality import normalize_decision_signal_data_quality
from src.services.decision_signal_service import DecisionSignalService
from src.storage import AnalysisHistory, DatabaseManager
from src.utils.data_processing import parse_json_field
from src.utils.sniper_points import find_sniper_points, parse_sniper_value
UNSUPPORTED_PERSIST_MESSAGE = (
"Persisting reassessed decision_profile signals is tracked by #1757."
)
class DecisionSignalSourceReportNotFoundError(Exception):
"""Raised when the requested source report does not exist."""
@@ -40,15 +36,25 @@ class DecisionSignalUnsupportedReportSnapshotError(Exception):
"""Raised when the persisted report snapshot is insufficient for reassess."""
class DecisionSignalReassessUnsupportedOperationError(Exception):
"""Raised when the request asks for a future reassess operation."""
class DecisionSignalReassessGuardrailBlockedError(Exception):
"""Raised when persist recomputation has no safe signal to store."""
def __init__(self, *, blocked_reason: str, warnings: list[dict[str, object]]) -> None:
self.blocked_reason = blocked_reason
self.warnings = warnings
super().__init__(blocked_reason)
class DecisionSignalReassessService:
"""Build preview-only reassess responses without touching DecisionSignal rows."""
"""Recompute a profile signal and optionally persist the authoritative result."""
def __init__(self, db: Optional[DatabaseManager] = None) -> None:
def __init__(
self,
db: Optional[DatabaseManager] = None,
signal_service: Optional[DecisionSignalService] = None,
) -> None:
self.db = db or DatabaseManager.get_instance()
self.signal_service = signal_service or DecisionSignalService(db_manager=self.db)
def reassess(
self,
@@ -57,8 +63,6 @@ class DecisionSignalReassessService:
decision_profile: str,
persist: bool = False,
) -> dict[str, Any]:
if persist:
raise DecisionSignalReassessUnsupportedOperationError(UNSUPPORTED_PERSIST_MESSAGE)
decision_profile_norm = normalize_decision_profile(decision_profile)
if decision_profile_norm is None:
raise ValueError("decision_profile is required")
@@ -94,7 +98,7 @@ class DecisionSignalReassessService:
"data_quality_level": data_quality_level,
"guardrail_result": policy.guardrail_result.as_dict(),
}
preview = {
preview: dict[str, Any] = {
"action": preview_candidate.action,
"score": preview_candidate.score,
"confidence": preview_candidate.confidence,
@@ -109,14 +113,90 @@ class DecisionSignalReassessService:
"watch_conditions": preview_candidate.watch_conditions,
"metadata": metadata,
}
if not persist:
return {
"preview": preview,
"item": None,
"created": False,
"persist_status": None,
"warnings": policy.warnings,
"blocked_reason": policy.blocked_reason,
}
if not policy.guardrail_result.passed:
raise DecisionSignalReassessGuardrailBlockedError(
blocked_reason=policy.blocked_reason or "actionable_signal_blocked_by_guardrail",
warnings=policy.warnings,
)
payload = _build_persist_payload(
record,
raw_result=raw_result,
decision_profile=decision_profile_norm,
candidate=preview_candidate,
metadata=metadata,
)
market_phase_summary = _as_mapping(context_snapshot.get("market_phase_summary"))
if not market_phase_summary:
market_phase_summary = _as_mapping(raw_result.get("market_phase_summary"))
try:
outcome = self.signal_service.create_history_bound_signal_with_outcome(
payload,
history_created_at=getattr(record, "created_at", None),
market_phase_summary=market_phase_summary,
)
except ValueError as exc:
raise DecisionSignalUnsupportedReportSnapshotError(
f"source report snapshot cannot produce a valid decision signal: {exc}"
) from exc
return {
"preview": None,
"item": outcome.item,
"created": outcome.created,
"persist_status": outcome.disposition,
"warnings": policy.warnings,
"blocked_reason": None,
}
def _build_persist_payload(
record: AnalysisHistory,
*,
raw_result: Mapping[str, Any],
decision_profile: str,
candidate: DecisionSignalCandidate,
metadata: dict[str, Any],
) -> dict[str, Any]:
stock_code = str(getattr(record, "code", "") or "").strip()
market = _infer_market(stock_code)
if not stock_code or market is None:
raise DecisionSignalUnsupportedReportSnapshotError("source report has no supported stock identity")
return {
"stock_code": stock_code,
"stock_name": getattr(record, "name", None),
"market": market,
"source_type": "analysis",
"source_report_id": int(getattr(record, "id")),
"source_agent": "decision_profile_reassess",
"trigger_source": "web:decision_profile_reassess",
"decision_profile": decision_profile,
"market_phase": candidate.market_phase,
"action": candidate.action,
"score": candidate.score,
"confidence": candidate.confidence,
"horizon": candidate.horizon,
"entry_low": candidate.entry_low,
"entry_high": candidate.entry_high,
"stop_loss": candidate.stop_loss,
"target_price": candidate.target_price,
"invalidation": candidate.invalidation,
"reason": candidate.reason,
"risk_summary": candidate.risk_summary,
"watch_conditions": candidate.watch_conditions,
"metadata": metadata,
"report_language": raw_result.get("report_language"),
}
def _build_candidate(
record: AnalysisHistory,

View File

@@ -6,12 +6,16 @@ from __future__ import annotations
import json
import logging
import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple, get_args
from typing import Any, Dict, List, Literal, Optional, Tuple, get_args
from data_provider.base import canonical_stock_code, normalize_stock_code
from src.core.trading_calendar import MarketPhase
from src.repositories.decision_signal_repo import DecisionSignalRepository
from src.repositories.decision_signal_repo import (
DecisionSignalCreateResult,
DecisionSignalRepository,
)
from src.repositories.portfolio_repo import PortfolioRepository
from src.report_language import normalize_report_language
from src.schemas.decision_action import (
@@ -73,6 +77,33 @@ class DecisionSignalStorageError(RuntimeError):
"""Raised when persisted decision-signal data is internally inconsistent."""
DecisionSignalWriteDisposition = Literal["created", "existing", "refreshed"]
@dataclass(frozen=True)
class DecisionSignalWriteOutcome:
"""Typed internal result for the single DecisionSignal write path."""
item: Dict[str, Any]
created: bool
refreshed: bool
duplicate: bool
def __post_init__(self) -> None:
if sum((self.created, self.refreshed, self.duplicate)) != 1:
raise DecisionSignalStorageError("invalid DecisionSignal write outcome")
@property
def disposition(self) -> DecisionSignalWriteDisposition:
if self.created:
return "created"
if self.refreshed:
return "refreshed"
if self.duplicate:
return "existing"
raise DecisionSignalStorageError("DecisionSignal write outcome has no disposition")
class DecisionSignalService:
"""Business logic for DecisionSignal storage, querying, and serialization."""
@@ -87,18 +118,74 @@ class DecisionSignalService:
self.db = db_manager or getattr(self.repo, "db", None) or DatabaseManager.get_instance()
def create_signal(self, payload: Dict[str, Any]) -> Dict[str, Any]:
fields, lifecycle = self._normalize_payload(payload)
result = self.repo.create_if_absent(
fields,
allow_relaxed_horizon_fill=lifecycle["horizon_defaulted"],
)
outcome = self.create_signal_with_outcome(payload)
return {"item": outcome.item, "created": outcome.created}
def create_signal_with_outcome(self, payload: Dict[str, Any]) -> DecisionSignalWriteOutcome:
"""Create through the canonical path while preserving repository disposition."""
result = self._store_signal(payload)
# Active duplicates can be retries after a prior partial create; rerun invalidation to repair old opposing signals.
if result.row.status == "active":
self._invalidate_opposing_active_signals(
result.row,
reference_at=result.invalidation_reference_at,
)
return {"item": self._serialize(result.row), "created": result.created}
return self._write_outcome(result)
def create_history_bound_signal_with_outcome(
self,
payload: Dict[str, Any],
*,
history_created_at: Optional[datetime],
market_phase_summary: Any = None,
) -> DecisionSignalWriteOutcome:
"""Persist a report-derived signal on the source report's timeline."""
history_payload = dict(payload)
self._apply_history_bound_lifecycle(
history_payload,
created_at=history_created_at,
market_phase_summary=market_phase_summary,
)
result = self._store_signal(history_payload)
if result.row.status == "active":
if result.row.created_at is None:
raise DecisionSignalStorageError(
"history-bound DecisionSignal has no created_at"
)
self._invalidate_opposing_active_signals(
result.row,
reference_at=result.row.created_at,
)
self._invalidate_history_bound_if_superseded(result.row.id)
final_row = self.repo.get(result.row.id)
if final_row is None:
raise DecisionSignalStorageError(
f"history-bound DecisionSignal disappeared after write: {result.row.id}"
)
return self._write_outcome(result, row=final_row)
def _store_signal(self, payload: Dict[str, Any]) -> DecisionSignalCreateResult:
fields, lifecycle = self._normalize_payload(payload)
return self.repo.create_if_absent(
fields,
allow_relaxed_horizon_fill=lifecycle["horizon_defaulted"],
)
def _write_outcome(
self,
result: DecisionSignalCreateResult,
*,
row: Optional[DecisionSignalRecord] = None,
) -> DecisionSignalWriteOutcome:
return DecisionSignalWriteOutcome(
item=self._serialize(row if row is not None else result.row),
created=result.created,
refreshed=result.refreshed,
duplicate=result.duplicate,
)
def get_signal(self, signal_id: int) -> Dict[str, Any]:
row = self.repo.get(signal_id)
@@ -426,14 +513,10 @@ class DecisionSignalService:
)
if payload is None:
return
self._apply_history_backfill_lifecycle(
self.create_history_bound_signal_with_outcome(
payload,
created_at=getattr(record, "created_at", None),
history_created_at=getattr(record, "created_at", None),
)
created = self.create_signal(payload)
signal_id = created.get("item", {}).get("id")
if isinstance(signal_id, int):
self._invalidate_history_backfill_if_superseded(signal_id)
except Exception as exc:
logger.warning(
"Decision signal lazy backfill failed: source_report_id=%s error=%s",
@@ -548,22 +631,36 @@ class DecisionSignalService:
return text
return None
def _apply_history_backfill_lifecycle(
def _apply_history_bound_lifecycle(
self,
payload: Dict[str, Any],
*,
created_at: Optional[datetime],
market_phase_summary: Any = None,
) -> None:
"""Anchor lazy backfill expiry to the report time instead of query time."""
"""Anchor a history-derived signal to the source report time."""
if created_at is None:
return
if not isinstance(created_at, datetime):
raise ValueError("source report created_at is required for persistence")
history_created_at = self._coerce_history_created_at_to_utc_naive(created_at)
if history_created_at is None:
payload["status"] = "expired"
return
payload["_created_at_override"] = history_created_at
payload["status"] = "active"
payload.pop("expires_at", None)
sanitized_phase_summary = self._sanitize_history_market_phase_summary(
market_phase_summary
)
if sanitized_phase_summary:
raw_metadata = payload.get("metadata")
if raw_metadata is None:
metadata: Dict[str, Any] = {}
elif isinstance(raw_metadata, dict):
metadata = dict(raw_metadata)
else:
raise ValueError("metadata must be an object")
metadata["market_phase_summary"] = sanitized_phase_summary
payload["metadata"] = metadata
horizon = payload.get("horizon") or self._default_horizon(
action=str(payload.get("action") or ""),
market_phase=payload.get("market_phase"),
@@ -571,7 +668,7 @@ class DecisionSignalService:
if horizon:
payload["horizon"] = horizon
expires_at = self._history_backfill_expires_at(
expires_at = self._history_bound_expires_at(
created_at=history_created_at,
horizon=horizon,
market=str(payload.get("market") or ""),
@@ -583,6 +680,22 @@ class DecisionSignalService:
if self._is_expired(expires_at):
payload["status"] = "expired"
@staticmethod
def _sanitize_history_market_phase_summary(value: Any) -> Dict[str, Any]:
if not isinstance(value, dict):
return {}
allowed_fields = (
"phase",
"session_date",
"minutes_to_open",
"minutes_to_close",
)
return {
field_name: value[field_name]
for field_name in allowed_fields
if value.get(field_name) not in (None, "")
}
@staticmethod
def _coerce_history_created_at_to_utc_naive(value: datetime) -> datetime:
if value.tzinfo is not None:
@@ -597,7 +710,7 @@ class DecisionSignalService:
except (OverflowError, OSError):
return to_utc_naive_datetime(value)
def _invalidate_history_backfill_if_superseded(self, signal_id: int) -> None:
def _invalidate_history_bound_if_superseded(self, signal_id: int) -> None:
row = self.repo.get(signal_id)
if row is None or row.status != "active":
return
@@ -624,7 +737,7 @@ class DecisionSignalService:
)
if updated is None:
logger.warning(
"Decision signal disappeared before stale backfill invalidation: "
"Decision signal disappeared before history-bound invalidation: "
"signal_id=%s invalidated_by=%s",
row.id,
newer_row.id,
@@ -632,7 +745,7 @@ class DecisionSignalService:
return
@classmethod
def _history_backfill_expires_at(
def _history_bound_expires_at(
cls,
*,
created_at: datetime,

View File

@@ -14,6 +14,7 @@ from api.v1.schemas.stocks import StockQuote
DECISION_SIGNAL_PATHS = (
"/api/v1/decision-signals",
"/api/v1/decision-signals/reassess",
"/api/v1/decision-signals/outcomes/run",
"/api/v1/decision-signals/outcomes",
"/api/v1/decision-signals/outcomes/stats",
@@ -36,7 +37,12 @@ DECISION_SIGNAL_SCHEMAS = (
"DecisionSignalOutcomeRunResponse",
"DecisionSignalOutcomeStatsBucket",
"DecisionSignalOutcomeStatsResponse",
"DecisionSignalPreview",
"DecisionSignalReassessErrorResponse",
"DecisionSignalReassessRequest",
"DecisionSignalReassessResponse",
"DecisionSignalStatusUpdateRequest",
"DecisionSignalWarning",
)
P6_SIGNAL_LINKED_PATHS = (
"/api/v1/alerts/triggers",

View File

@@ -22,7 +22,7 @@ def test_policy_keeps_valid_snapshot_action_without_profile_upgrade() -> None:
assert result.guardrail_result.adjusted is False
def test_policy_blocks_buy_with_missing_confidence_as_safe_display_action() -> None:
def test_policy_safely_downgrades_buy_with_missing_confidence() -> None:
result = apply_decision_profile_policy(
DecisionSignalCandidate(
action="buy",
@@ -39,10 +39,13 @@ def test_policy_blocks_buy_with_missing_confidence_as_safe_display_action() -> N
assert result.guardrail_result.raw_action == "buy"
assert result.guardrail_result.final_action == "watch"
assert result.candidate.action == "watch"
assert result.guardrail_result.passed is False
assert result.guardrail_result.passed is True
assert result.guardrail_result.adjusted is True
assert "missing_confidence" in result.guardrail_result.violations
assert result.blocked_reason
assert {warning["code"] for warning in result.warnings} >= {"action_blocked_by_guardrail"}
assert result.guardrail_result.adjustments
assert result.blocked_reason is None
assert {warning["code"] for warning in result.warnings} == {"action_adjusted_by_guardrail"}
assert all(warning.get("message") for warning in result.warnings)
def test_policy_requires_explicit_invalidation_for_aggressive_buy() -> None:
@@ -100,5 +103,12 @@ def test_policy_records_price_relationship_violations() -> None:
assert result.guardrail_result.final_action == "alert"
assert result.guardrail_result.adjusted is True
assert result.guardrail_result.passed is False
assert "entry_range_invalid" in result.guardrail_result.violations
assert "stop_loss_not_below_target_price" in result.guardrail_result.violations
assert result.blocked_reason
assert {warning["code"] for warning in result.warnings} == {
"action_adjusted_by_guardrail",
"action_blocked_by_guardrail",
}
assert all(warning.get("message") for warning in result.warnings)

View File

@@ -22,7 +22,10 @@ except ModuleNotFoundError:
import src.auth as auth
from api.app import create_app
from src.analyzer import AnalysisResult
from src.config import Config
from src.services.decision_signal_extractor import extract_and_persist_from_analysis_result
from src.services.decision_signal_service import DecisionSignalService
from src.storage import AnalysisHistory, DatabaseManager, DecisionSignalRecord, PortfolioAccount, PortfolioPosition, utc_naive_now
@@ -1355,6 +1358,16 @@ def _save_reassess_history(
return int(row.id)
def _set_reassess_history_created_at(
db: DatabaseManager,
record_id: int,
created_at: datetime | None,
) -> None:
with db.session_scope() as session:
row = session.query(AnalysisHistory).filter(AnalysisHistory.id == record_id).one()
row.created_at = created_at
def _valid_reassess_raw(**overrides) -> dict:
raw = {
"action": "buy",
@@ -1391,13 +1404,44 @@ def _valid_reassess_context() -> dict:
}
def test_reassess_persist_true_rejects_before_db_lookup(client_and_db, monkeypatch) -> None:
def _persist_auto_balanced_signal(
db: DatabaseManager,
*,
source_report_id: int,
raw_result: dict | None = None,
context_snapshot: dict | None = None,
) -> dict:
raw = raw_result or _valid_reassess_raw(invalidation="跌破关键支撑")
result = AnalysisResult(
code="600519",
name="贵州茅台",
sentiment_score=raw.get("sentiment_score", 72),
trend_prediction="震荡上行",
operation_advice=raw.get("operation_advice", "买入"),
decision_type="buy",
confidence_level=raw.get("confidence_level", ""),
analysis_summary=raw.get("analysis_summary", "趋势改善但需要确认。"),
risk_warning=raw.get("risk_warning", "跌破关键支撑需退出。"),
report_language="zh",
action=raw.get("action", "buy"),
)
result.dashboard = raw.get("dashboard")
persisted = extract_and_persist_from_analysis_result(
result,
context_snapshot=context_snapshot or _valid_reassess_context(),
source_report_id=source_report_id,
trace_id=f"trace-auto-{source_report_id}",
query_source="api",
report_type="full",
profile_source="auto_default",
service=DecisionSignalService(db_manager=db),
)
assert persisted is not None
return persisted["item"]
def test_reassess_persist_true_inherits_source_report_not_found(client_and_db) -> None:
client, db = client_and_db
def fail_service(*_args, **_kwargs):
raise AssertionError("DecisionSignalReassessService must not be instantiated for persist=true")
monkeypatch.setattr("api.v1.endpoints.decision_signals.DecisionSignalReassessService", fail_service)
before = _decision_signal_count(db)
response = client.post(
@@ -1409,11 +1453,8 @@ def test_reassess_persist_true_rejects_before_db_lookup(client_and_db, monkeypat
},
)
assert response.status_code == 400, response.text
assert response.json()["error"] == "unsupported_operation"
assert response.json()["message"] == (
"Persisting reassessed decision_profile signals is tracked by #1757."
)
assert response.status_code == 404, response.text
assert response.json()["error"] == "source_report_not_found"
assert _decision_signal_count(db) == before
@@ -1520,8 +1561,16 @@ def test_reassess_success_preview_is_read_only_and_uses_opaque_metadata(client_a
context_snapshot=_valid_reassess_context(),
)
monkeypatch.setattr(
"src.services.decision_signal_service.DecisionSignalService.create_signal",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("create_signal must not be called")),
"src.services.decision_signal_service.DecisionSignalService.create_signal_with_outcome",
lambda *_args, **_kwargs: (
_ for _ in ()
).throw(AssertionError("create_signal_with_outcome must not be called")),
)
monkeypatch.setattr(
"src.services.decision_signal_service.DecisionSignalService.create_history_bound_signal_with_outcome",
lambda *_args, **_kwargs: (
_ for _ in ()
).throw(AssertionError("create_history_bound_signal_with_outcome must not be called")),
)
monkeypatch.setattr(
"src.services.decision_signal_service.DecisionSignalService.list_signals",
@@ -1542,6 +1591,7 @@ def test_reassess_success_preview_is_read_only_and_uses_opaque_metadata(client_a
payload = response.json()
assert payload["item"] is None
assert payload["created"] is False
assert payload["persist_status"] is None
assert payload["preview"]["action"] == payload["preview"]["metadata"]["guardrail_result"]["final_action"]
assert payload["preview"]["metadata"]["decision_profile"] == "balanced"
assert payload["preview"]["metadata"]["profile_source"] == "user_selected"
@@ -1598,7 +1648,7 @@ def test_reassess_service_has_no_live_market_provider_imports() -> None:
assert "build_decision_signal_payload_from_report" not in source
def test_reassess_confidence_missing_buy_is_blocked_preview_not_persisted(client_and_db) -> None:
def test_reassess_confidence_missing_buy_is_safe_non_actionable_preview(client_and_db) -> None:
client, db = client_and_db
record_id = _save_reassess_history(
db,
@@ -1617,9 +1667,558 @@ def test_reassess_confidence_missing_buy_is_blocked_preview_not_persisted(client
guardrail = payload["preview"]["metadata"]["guardrail_result"]
assert guardrail["raw_action"] == "buy"
assert guardrail["final_action"] in {"watch", "alert"}
assert guardrail["passed"] is False
assert guardrail["passed"] is True
assert guardrail["adjusted"] is True
assert "missing_confidence" in guardrail["violations"]
assert payload["blocked_reason"]
assert payload["blocked_reason"] is None
assert payload["warnings"]
assert {warning["code"] for warning in payload["warnings"]} >= {"action_blocked_by_guardrail"}
assert {warning["code"] for warning in payload["warnings"]} == {"action_adjusted_by_guardrail"}
assert all(warning["message"] for warning in payload["warnings"])
assert _decision_signal_count(db) == before
def test_reassess_persist_writes_authoritative_item_and_deduplicates(client_and_db) -> None:
client, db = client_and_db
record_id = _save_reassess_history(
db,
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑且资金流转负"),
context_snapshot=_valid_reassess_context(),
)
request = {
"source_report_id": record_id,
"decision_profile": "aggressive",
"persist": True,
}
first = client.post("/api/v1/decision-signals/reassess", json=request)
assert first.status_code == 200, first.text
first_payload = first.json()
assert first_payload["preview"] is None
assert first_payload["created"] is True
assert first_payload["persist_status"] == "created"
item = first_payload["item"]
assert item["decision_profile"] == "aggressive"
assert item["source_type"] == "analysis"
assert item["source_report_id"] == record_id
assert item["source_agent"] == "decision_profile_reassess"
assert item["trigger_source"] == "web:decision_profile_reassess"
assert item["action"] == item["metadata"]["guardrail_result"]["final_action"]
assert item["metadata"]["profile_source"] == "user_selected"
assert item["metadata"]["profile_policy_version"] == "decision-profile-v1"
assert item["metadata"]["signal_generation_version"] == "decision-profile-reassess-v1"
assert item["metadata"]["scoring_version"] == "decision-profile-scoring-v1"
assert item["metadata"]["scoring_breakdown"]
assert item["metadata"]["data_quality_level"] == "medium"
assert item["metadata"]["guardrail_result"]["passed"] is True
second = client.post("/api/v1/decision-signals/reassess", json=request)
assert second.status_code == 200, second.text
assert second.json()["created"] is False
assert second.json()["persist_status"] == "existing"
assert second.json()["item"]["id"] == item["id"]
assert _decision_signal_count(db) == 1
timeline = client.get(
"/api/v1/decision-signals",
params={"stock_code": "600519", "decision_profile": "aggressive", "page_size": 100},
)
assert timeline.status_code == 200, timeline.text
assert [signal["id"] for signal in timeline.json()["items"]] == [item["id"]]
def test_reassess_persist_anchors_expired_signal_to_report_lifecycle(client_and_db) -> None:
client, db = client_and_db
report_created_at = utc_naive_now().replace(microsecond=0) - timedelta(days=30)
context = _valid_reassess_context()
context["market_phase_summary"] = {
"phase": "intraday",
"session_date": "2026-06-15",
"minutes_to_close": 5,
"ignored_private_field": "must-not-persist",
}
record_id = _save_reassess_history(
db,
raw_result=_valid_reassess_raw(
horizon="intraday",
invalidation="跌破关键支撑且资金流转负",
),
context_snapshot=context,
)
_set_reassess_history_created_at(db, record_id, report_created_at)
service = DecisionSignalService(db_manager=db)
expected_created_at = service._coerce_history_created_at_to_utc_naive(report_created_at)
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": True},
)
assert response.status_code == 200, response.text
payload = response.json()
item = payload["item"]
assert payload["persist_status"] == "created"
assert item["status"] == "expired"
assert datetime.fromisoformat(item["created_at"]) == expected_created_at
assert datetime.fromisoformat(item["expires_at"]) == expected_created_at + timedelta(minutes=5)
assert item["metadata"]["market_phase_summary"] == {
"phase": "intraday",
"session_date": "2026-06-15",
"minutes_to_close": 5,
}
listed = client.get(
"/api/v1/decision-signals",
params={"source_type": "analysis", "source_report_id": record_id, "page_size": 100},
)
assert listed.status_code == 200, listed.text
assert [listed_item["id"] for listed_item in listed.json()["items"]] == [item["id"]]
latest = client.get("/api/v1/decision-signals/latest/600519", params={"limit": 5})
assert latest.status_code == 200, latest.text
assert latest.json()["items"] == []
def test_reassess_persist_uses_saved_raw_phase_summary_when_context_summary_is_missing(
client_and_db,
) -> None:
client, db = client_and_db
report_created_at = utc_naive_now().replace(microsecond=0) - timedelta(days=30)
raw = _valid_reassess_raw(
horizon="intraday",
invalidation="跌破关键支撑",
market_phase_summary={"phase": "intraday", "minutes_to_close": 7},
)
context = _valid_reassess_context()
context.pop("market_phase_summary")
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
_set_reassess_history_created_at(db, record_id, report_created_at)
expected_created_at = DecisionSignalService(
db_manager=db
)._coerce_history_created_at_to_utc_naive(report_created_at)
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
)
assert response.status_code == 200, response.text
item = response.json()["item"]
assert item["status"] == "expired"
assert datetime.fromisoformat(item["expires_at"]) == expected_created_at + timedelta(minutes=7)
assert item["metadata"]["market_phase_summary"] == {
"phase": "intraday",
"minutes_to_close": 7,
}
def test_reassess_persist_returns_final_invalidated_item_without_harming_newer_signal(
client_and_db,
) -> None:
client, db = client_and_db
report_created_at = utc_naive_now().replace(microsecond=0) - timedelta(days=1)
context = _valid_reassess_context()
context["market_phase_summary"] = {"phase": "postmarket"}
record_id = _save_reassess_history(
db,
raw_result=_valid_reassess_raw(
horizon="3d",
invalidation="跌破关键支撑且资金流转负",
),
context_snapshot=context,
)
_set_reassess_history_created_at(db, record_id, report_created_at)
newer_sell = client.post(
"/api/v1/decision-signals",
json=_payload(
action="sell",
score=20,
decision_profile="aggressive",
source_report_id=record_id + 1000,
trace_id="trace-newer-aggressive-sell",
market_phase="postmarket",
horizon="3d",
),
).json()["item"]
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": True},
)
assert response.status_code == 200, response.text
payload = response.json()
item = payload["item"]
assert payload["persist_status"] == "created"
assert item["status"] == "invalidated"
assert item["metadata"]["invalidated_by_signal_id"] == newer_sell["id"]
assert client.get(f"/api/v1/decision-signals/{newer_sell['id']}").json()["status"] == "active"
latest = client.get("/api/v1/decision-signals/latest/600519", params={"limit": 5})
assert [latest_item["id"] for latest_item in latest.json()["items"]] == [newer_sell["id"]]
def test_reassess_history_refresh_uses_created_at_not_updated_at_for_invalidation(
client_and_db,
) -> None:
client, db = client_and_db
report_created_at = utc_naive_now().replace(microsecond=0) - timedelta(days=1)
raw = _valid_reassess_raw(horizon="3d", invalidation="跌破关键支撑")
context = _valid_reassess_context()
context["market_phase_summary"] = {"phase": "postmarket"}
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
_set_reassess_history_created_at(db, record_id, report_created_at)
auto_item = _persist_auto_balanced_signal(
db,
source_report_id=record_id,
raw_result=raw,
context_snapshot=context,
)
expected_created_at = DecisionSignalService(
db_manager=db
)._coerce_history_created_at_to_utc_naive(report_created_at)
with db.session_scope() as session:
row = session.query(DecisionSignalRecord).filter(DecisionSignalRecord.id == auto_item["id"]).one()
row.created_at = expected_created_at
row.status = "expired"
row.expires_at = utc_naive_now() - timedelta(minutes=1)
row.updated_at = utc_naive_now() - timedelta(minutes=1)
newer_sell = client.post(
"/api/v1/decision-signals",
json=_payload(
action="sell",
score=20,
decision_profile="balanced",
source_report_id=record_id + 2000,
trace_id="trace-newer-balanced-sell",
market_phase="postmarket",
horizon="3d",
),
).json()["item"]
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["persist_status"] == "refreshed"
assert payload["item"]["id"] == auto_item["id"]
assert payload["item"]["status"] == "invalidated"
assert payload["item"]["metadata"]["invalidated_by_signal_id"] == newer_sell["id"]
assert client.get(f"/api/v1/decision-signals/{newer_sell['id']}").json()["status"] == "active"
def test_reassess_persist_requires_report_time_without_breaking_preview(client_and_db) -> None:
client, db = client_and_db
record_id = _save_reassess_history(
db,
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑"),
context_snapshot=_valid_reassess_context(),
)
_set_reassess_history_created_at(db, record_id, None)
before = _decision_signal_count(db)
preview = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": False},
)
persisted = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
)
assert preview.status_code == 200, preview.text
assert preview.json()["preview"] is not None
assert persisted.status_code == 400, persisted.text
assert persisted.json()["error"] == "unsupported_report_snapshot"
assert _decision_signal_count(db) == before
def test_reassess_balanced_persist_creates_when_auto_extraction_has_no_signal(client_and_db) -> None:
client, db = client_and_db
record_id = _save_reassess_history(
db,
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑"),
context_snapshot=_valid_reassess_context(),
)
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["persist_status"] == "created"
assert payload["created"] is True
assert payload["item"]["decision_profile"] == "balanced"
assert payload["item"]["source_agent"] == "decision_profile_reassess"
assert payload["item"]["trigger_source"] == "web:decision_profile_reassess"
assert payload["item"]["metadata"]["profile_source"] == "user_selected"
assert payload["item"]["metadata"]["scoring_breakdown"]
assert _decision_signal_count(db) == 1
def test_reassess_balanced_persist_reuses_actual_auto_generated_signal(client_and_db) -> None:
client, db = client_and_db
raw = _valid_reassess_raw(invalidation="跌破关键支撑")
context = _valid_reassess_context()
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
auto_item = _persist_auto_balanced_signal(
db,
source_report_id=record_id,
raw_result=raw,
context_snapshot=context,
)
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["persist_status"] == "existing"
assert payload["created"] is False
assert payload["item"]["id"] == auto_item["id"]
assert payload["item"]["source_agent"] == auto_item["source_agent"]
assert payload["item"]["trigger_source"] == "api"
assert payload["item"]["metadata"]["profile_source"] == "auto_default"
assert payload["item"]["metadata"]["signal_generation_version"] == "legacy-report-extractor-v1"
assert "scoring_breakdown" not in payload["item"]["metadata"]
assert _decision_signal_count(db) == 1
def test_reassess_balanced_persist_refreshes_expired_auto_generated_signal(client_and_db) -> None:
client, db = client_and_db
raw = _valid_reassess_raw(invalidation="跌破关键支撑")
context = _valid_reassess_context()
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
auto_item = _persist_auto_balanced_signal(
db,
source_report_id=record_id,
raw_result=raw,
context_snapshot=context,
)
with db.session_scope() as session:
row = session.query(DecisionSignalRecord).filter(DecisionSignalRecord.id == auto_item["id"]).one()
row.status = "expired"
row.expires_at = utc_naive_now() - timedelta(minutes=1)
row.updated_at = utc_naive_now() - timedelta(minutes=1)
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["persist_status"] == "refreshed"
assert payload["created"] is False
assert payload["item"]["id"] == auto_item["id"]
assert payload["item"]["status"] == "active"
assert payload["item"]["created_at"] == auto_item["created_at"]
assert payload["item"]["source_agent"] == auto_item["source_agent"]
assert payload["item"]["trigger_source"] == "api"
assert payload["item"]["metadata"]["profile_source"] == "user_selected"
assert payload["item"]["metadata"]["signal_generation_version"] == "decision-profile-reassess-v1"
assert payload["item"]["metadata"]["scoring_breakdown"]
assert payload["item"]["action"] == payload["item"]["metadata"]["guardrail_result"]["final_action"]
assert _decision_signal_count(db) == 1
def test_reassess_balanced_persist_reports_relaxed_phase_fill_without_overwriting_auto_metadata(
client_and_db,
) -> None:
client, db = client_and_db
raw = _valid_reassess_raw(invalidation="跌破关键支撑")
context = _valid_reassess_context()
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
auto_item = _persist_auto_balanced_signal(
db,
source_report_id=record_id,
raw_result=raw,
context_snapshot=context,
)
with db.session_scope() as session:
row = session.query(DecisionSignalRecord).filter(DecisionSignalRecord.id == auto_item["id"]).one()
row.market_phase = None
row.updated_at = utc_naive_now()
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["persist_status"] == "refreshed"
assert payload["created"] is False
assert payload["item"]["id"] == auto_item["id"]
assert payload["item"]["market_phase"] == "intraday"
assert payload["item"]["source_agent"] == auto_item["source_agent"]
assert payload["item"]["trigger_source"] == "api"
assert payload["item"]["metadata"]["profile_source"] == "auto_default"
assert payload["item"]["metadata"]["signal_generation_version"] == "legacy-report-extractor-v1"
assert "scoring_breakdown" not in payload["item"]["metadata"]
assert _decision_signal_count(db) == 1
@pytest.mark.parametrize("terminal_status", ["closed", "invalidated", "archived"])
def test_reassess_balanced_persist_returns_terminal_auto_signal_without_reactivation(
client_and_db,
terminal_status,
) -> None:
client, db = client_and_db
raw = _valid_reassess_raw(invalidation="跌破关键支撑")
context = _valid_reassess_context()
record_id = _save_reassess_history(db, raw_result=raw, context_snapshot=context)
auto_item = _persist_auto_balanced_signal(
db,
source_report_id=record_id,
raw_result=raw,
context_snapshot=context,
)
with db.session_scope() as session:
row = session.query(DecisionSignalRecord).filter(DecisionSignalRecord.id == auto_item["id"]).one()
row.status = terminal_status
row.updated_at = utc_naive_now()
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "balanced", "persist": True},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["persist_status"] == "existing"
assert payload["created"] is False
assert payload["item"]["id"] == auto_item["id"]
assert payload["item"]["status"] == terminal_status
assert payload["item"]["metadata"]["profile_source"] == "auto_default"
assert _decision_signal_count(db) == 1
def test_reassess_persist_distinguishes_profiles(client_and_db) -> None:
client, db = client_and_db
record_id = _save_reassess_history(
db,
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑"),
context_snapshot=_valid_reassess_context(),
)
aggressive = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": True},
)
conservative = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "conservative", "persist": True},
)
assert aggressive.status_code == 200, aggressive.text
assert conservative.status_code == 200, conservative.text
assert aggressive.json()["item"]["id"] != conservative.json()["item"]["id"]
assert aggressive.json()["item"]["decision_profile"] == "aggressive"
assert conservative.json()["item"]["decision_profile"] == "conservative"
assert _decision_signal_count(db) == 2
def test_reassess_persist_invalidates_only_same_profile_opposing_signal(client_and_db) -> None:
client, db = client_and_db
balanced_sell = client.post(
"/api/v1/decision-signals",
json=_payload(
action="sell",
score=20,
decision_profile="balanced",
source_report_id=4101,
trace_id="trace-balanced-sell",
),
).json()["item"]
aggressive_sell = client.post(
"/api/v1/decision-signals",
json=_payload(
action="sell",
score=20,
decision_profile="aggressive",
source_report_id=4102,
trace_id="trace-aggressive-sell",
),
).json()["item"]
record_id = _save_reassess_history(
db,
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑"),
context_snapshot=_valid_reassess_context(),
)
persisted = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": True},
)
assert persisted.status_code == 200, persisted.text
assert persisted.json()["item"]["action"] == "buy"
assert client.get(f"/api/v1/decision-signals/{aggressive_sell['id']}").json()["status"] == "invalidated"
assert client.get(f"/api/v1/decision-signals/{balanced_sell['id']}").json()["status"] == "active"
def test_reassess_persist_saves_safe_guardrail_downgrade_with_audit_metadata(client_and_db) -> None:
client, db = client_and_db
record_id = _save_reassess_history(
db,
raw_result=_valid_reassess_raw(confidence_level=None),
context_snapshot=_valid_reassess_context(),
)
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": "aggressive", "persist": True},
)
assert response.status_code == 200, response.text
payload = response.json()
guardrail = payload["item"]["metadata"]["guardrail_result"]
assert payload["item"]["action"] == "watch"
assert guardrail["raw_action"] == "buy"
assert guardrail["final_action"] == "watch"
assert guardrail["passed"] is True
assert guardrail["adjusted"] is True
assert guardrail["violations"]
assert guardrail["adjustments"]
assert payload["warnings"]
assert all(warning["message"] for warning in payload["warnings"])
assert _decision_signal_count(db) == 1
@pytest.mark.parametrize("decision_profile", ["balanced", "aggressive"])
def test_reassess_persist_guardrail_block_returns_structured_error_without_write(
client_and_db,
decision_profile,
) -> None:
client, db = client_and_db
record_id = _save_reassess_history(
db,
raw_result=_valid_reassess_raw(invalidation="跌破关键支撑"),
context_snapshot=_valid_reassess_context(),
stop_loss=1900,
take_profit=1800,
)
before = _decision_signal_count(db)
response = client.post(
"/api/v1/decision-signals/reassess",
json={"source_report_id": record_id, "decision_profile": decision_profile, "persist": True},
)
assert response.status_code == 400, response.text
payload = response.json()
assert payload["error"] == "guardrail_blocked"
assert payload["blocked_reason"]
assert {warning["code"] for warning in payload["warnings"]} >= {
"action_adjusted_by_guardrail",
"action_blocked_by_guardrail",
}
assert all(warning["message"] for warning in payload["warnings"])
assert "created" not in payload
assert _decision_signal_count(db) == before

View File

@@ -28,6 +28,7 @@ def test_decision_signal_topic_references_live_api_schema_and_docs() -> None:
for path in (
"/api/v1/decision-signals",
"/api/v1/decision-signals/reassess",
"/api/v1/decision-signals/latest/{stock_code}",
"/api/v1/decision-signals/outcomes/run",
"/api/v1/decision-signals/{signal_id}/feedback",
@@ -38,6 +39,8 @@ def test_decision_signal_topic_references_live_api_schema_and_docs() -> None:
for schema_name in (
"DecisionSignalCreateRequest",
"DecisionSignalItem",
"DecisionSignalReassessRequest",
"DecisionSignalReassessResponse",
"DecisionSignalOutcomeItem",
"DecisionSignalFeedbackRequest",
"PortfolioDecisionSignalRiskBlock",
@@ -48,6 +51,16 @@ def test_decision_signal_topic_references_live_api_schema_and_docs() -> None:
assert "sanitize_decision_signal_payload()" in topic
assert "DECISION_SIGNAL_*" in topic
assert "revert" in topic
assert "persist=true" in topic
assert "guardrail_blocked" in topic
assert "MIN_ACTIONABLE_CONFIDENCE = 0.5" in topic
assert "source_agent=decision_profile_reassess" in topic
assert "trigger_source=web:decision_profile_reassess" in topic
assert "scoring_breakdown" in topic
assert "persist_status=created" in topic
assert "`existing` item 原样保留" in topic
assert "active relaxed dimension-fill 只补齐缺失的 horizon/market phase" in topic
assert "HTTP 422" in topic
assert "decision-signals.md" in full_guide
assert "decision-signals.md" in full_guide_en
assert "decision-signals.md" in index