Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions consensusinvest/report_module/runtime_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,16 @@ def latest_judgment_for_entity(self, entity_id: str) -> JudgmentRecord | None:
while True:
rows, total = self.workflow_repository.list_runs(ticker=ticker, limit=limit, offset=offset)
for run in rows:
if run.entity_id is not None and run.entity_id != entity_id:
run_entity_id = getattr(run, "entity_id", None)
if run_entity_id is not None and run_entity_id != entity_id:
continue
if stock_code is not None and run.stock_code not in {None, stock_code}:
run_stock_code = getattr(run, "stock_code", None)
if stock_code is not None and run_stock_code not in {None, stock_code}:
continue
judgment = self.agent_repository.get_judgment_by_workflow(run.workflow_run_id)
workflow_run_id = getattr(run, "workflow_run_id", None)
if not workflow_run_id:
continue
judgment = self.agent_repository.get_judgment_by_workflow(workflow_run_id)
if judgment is not None:
return judgment
offset += len(rows)
Expand Down
15 changes: 15 additions & 0 deletions consensusinvest/report_module/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,20 @@ class ActionView(TraceableModel):
source: Literal["main_judgment_summary"]


class HeroMetaItem(TraceableModel):
label: str
value: str


class HeroView(TraceableModel):
title: str
summary: str
status_note: str
source_note: str
limitation_note: str | None = None
meta: list[HeroMetaItem] = Field(default_factory=list)


class TraceRefs(TraceableModel):
evidence_ids: list[str] = Field(default_factory=list)
market_snapshot_ids: list[str] = Field(default_factory=list)
Expand All @@ -143,6 +157,7 @@ class StockAnalysisView(TraceableModel):
report_run_id: str
report_mode: ReportMode
data_state: DataState
hero: HeroView
action: ActionView | None = None
report: ReportBody
trace_refs: TraceRefs
Expand Down
81 changes: 78 additions & 3 deletions consensusinvest/report_module/stock_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
EventImpactItem,
EventImpactRankingView,
EvidenceMatch,
HeroMetaItem,
HeroView,
IndustryDetailsView,
IndustryLinks,
KeyEvidence,
Expand All @@ -55,6 +57,42 @@



def _hero_summary(reasoning: str | None) -> tuple[str, str | None]:
if not reasoning or not reasoning.strip():
return "", None
text = " ".join(reasoning.split())
implementation_note = None
marker = "原始判断说明包含英文或乱码,已按中文展示兜底处理。"
if marker in text:
text = text.replace(marker, "").strip()
implementation_note = "原始判断说明已按中文可读形式展示。"
for separator in ("\n\n", "。", ";", ";", "!", "!", "?", "?"):
if separator in text:
first = text.split(separator, 1)[0].strip()
if first:
return first + ("。" if separator == "。" else ""), implementation_note
return text[:120].rstrip(",,;;::") + ("…" if len(text) > 120 else ""), implementation_note


def _data_state_label(data_state: DataState) -> str:
return {
DataState.READY: "已就绪",
DataState.PARTIAL: "部分就绪",
DataState.MISSING: "资料缺失",
DataState.PENDING_REFRESH: "等待补齐",
DataState.REFRESHING: "正在补齐",
DataState.STALE: "待更新",
DataState.FAILED: "加载失败",
}[data_state]


def _report_mode_label(report_mode: ReportMode) -> str:
return {
ReportMode.WITH_WORKFLOW_TRACE: "主链路判断视图",
ReportMode.REPORT_GENERATION: "资料汇总视图",
}[report_mode]


def build_stock_search(
*,
reader: ReportRuntimeReader,
Expand Down Expand Up @@ -142,7 +180,17 @@ def build_stock_analysis_view(

action: ActionView | None = None
risks: list[RiskItem] = []
summary_text = ""
summary_text = (
"报告视图基于已入库 Evidence Structure 与 MarketSnapshot 拼装,"
"未运行主 workflow;不含 Agent Swarm 论证或 Judge 结论。"
)
hero_summary = "基于已入库资料整理当前标的要点,暂无主链路判断结论。"
hero_status_note = "仅基于已入库资料生成报告。"
hero_source_note = "当前页面未关联主 workflow Judgment。"
hero_limitation_note: str | None = "此视图用于资料编排与引用展示,不单独生成投资建议。"
hero_meta = [
HeroMetaItem(label="报告模式", value=_report_mode_label(ReportMode.REPORT_GENERATION)),
]
if judgment is not None:
action = ActionView(
label=_action_label(judgment.final_signal),
Expand All @@ -155,9 +203,20 @@ def build_stock_analysis_view(
for text in judgment.risk_notes
]
summary_text = judgment.reasoning
hero_summary, implementation_note = _hero_summary(judgment.reasoning)
positive_evidence_count = len(judgment.key_positive_evidence_ids)
negative_evidence_count = len(judgment.key_negative_evidence_ids)
hero_status_note = f"当前主判断为{action.label}。"
hero_source_note = "内容来自主 workflow 的 Judgment 摘要。"
hero_limitation_note = implementation_note
hero_meta = [
HeroMetaItem(label="报告模式", value=_report_mode_label(ReportMode.WITH_WORKFLOW_TRACE)),
HeroMetaItem(label="关键证据", value=f"{positive_evidence_count} 条正向 / {negative_evidence_count} 条负向"),
HeroMetaItem(label="风险提示", value=f"{len(judgment.risk_notes)} 条"),
]
else:
risks = _risk_disclosures(evidence_records)
summary_text = ";".join(_summary(detail) for detail in evidence_records[:3] if _summary(detail).strip())
hero_meta.append(HeroMetaItem(label="关键证据", value=f"{len(evidence_records)} 条已入库资料"))

key_evidence = [
KeyEvidence(
Expand Down Expand Up @@ -192,6 +251,14 @@ def build_stock_analysis_view(
if refresh_task_id is not None:
data_state = DataState.REFRESHING

report_mode = ReportMode.WITH_WORKFLOW_TRACE if has_workflow else ReportMode.REPORT_GENERATION
hero_meta.extend(
[
HeroMetaItem(label="数据状态", value=_data_state_label(data_state)),
HeroMetaItem(label="更新时间", value=_dt(judgment.created_at) if judgment else _now_iso()),
]
)

view = StockAnalysisView(
stock_code=_stock_code(entity) or stock_code,
ticker=ticker,
Expand All @@ -200,8 +267,16 @@ def build_stock_analysis_view(
workflow_run_id=judgment.workflow_run_id if judgment else None,
judgment_id=judgment.judgment_id if judgment else None,
report_run_id=report_run_id,
report_mode=ReportMode.WITH_WORKFLOW_TRACE if has_workflow else ReportMode.REPORT_GENERATION,
report_mode=report_mode,
data_state=data_state,
hero=HeroView(
title=entity.name,
summary=hero_summary,
status_note=hero_status_note,
source_note=hero_source_note,
limitation_note=hero_limitation_note,
meta=hero_meta,
),
action=action,
report=ReportBody(
title="个股研究聚合视图",
Expand Down
39 changes: 0 additions & 39 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 65 additions & 0 deletions frontend/src/api/errors.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
export const API_TIMEOUT_REASON = 'timeout';

export type ApiErrorBody = {
code?: string;
message?: string;
details?: Record<string, unknown>;
};

export const API_TIMEOUT_CODE = 'TIMEOUT';

export class ApiRequestError extends Error {
readonly status: number;
readonly path: string;
Expand Down Expand Up @@ -51,6 +55,9 @@ export async function readJsonResponse<T>(response: Response, path: string): Pro

export function formatApiError(error: unknown, fallback: string): string {
if (error instanceof ApiRequestError) {
if (error.code === API_TIMEOUT_CODE) {
return `${fallback}:加载 10 秒仍无结果,请稍后重试`;
}
const code = error.code ? `,错误码:${error.code}` : '';
return `${fallback}(${error.path},HTTP ${error.status}${code}):${error.message}`;
}
Expand All @@ -60,6 +67,64 @@ export function formatApiError(error: unknown, fallback: string): string {
return fallback;
}

export function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}

export function isTimeoutError(error: unknown): boolean {
return error instanceof ApiRequestError && error.code === API_TIMEOUT_CODE;
}

export function toApiError(error: unknown, path: string, reason?: unknown): unknown {
if (!isAbortError(error)) {
return error;
}

if (reason !== API_TIMEOUT_REASON) {
return error;
}

return new ApiRequestError({
status: 408,
path,
code: API_TIMEOUT_CODE,
message: '请求超时',
});
}

export function mergeAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
if (activeSignals.length === 0) {
return undefined;
}
if (activeSignals.length === 1) {
return activeSignals[0];
}

const controller = new AbortController();
const cleanup = new Map<AbortSignal, () => void>();

const abortFrom = (signal: AbortSignal) => {
for (const [currentSignal, listener] of cleanup) {
currentSignal.removeEventListener('abort', listener);
}
cleanup.clear();
controller.abort(signal.reason);
};

for (const signal of activeSignals) {
if (signal.aborted) {
abortFrom(signal);
return controller.signal;
}
const listener = () => abortFrom(signal);
cleanup.set(signal, listener);
signal.addEventListener('abort', listener, { once: true });
}

return controller.signal;
}

function parseJson(text: string): any {
if (!text.trim()) {
return null;
Expand Down
Loading