"""主通风机固定工作流:完整 PDF 提取 + 现成监测数据 → 一次 Qwen JSON 研判。""" from __future__ import annotations import asyncio from datetime import datetime import json import math from pathlib import Path import time from typing import Literal from uuid import uuid4 from zoneinfo import ZoneInfo import httpx from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator import config from agents.model_factory import _is_qwen35_model, _qwen_thinking_payload, normalize_thinking from billing.meter import UsageMeter from db import business_store, chat_store, db_conn, main_fan_store from extensions.main_fan import exploration, history_compaction, history_processing, workflow_json_repair, workflow_monitoring from extensions.main_fan.batch_context import scope_for from extensions.main_fan.schemas import MainFanResult from extensions.main_fan.workflow_documents import OCRSettings, PDFExtractor, service_url from extensions.main_fan.workflow_page_filter import select_pages from extensions.registry import load_extensions from obs import get_logger, get_trace_id, log_context from runtime import business, execution from runtime.context_meter import estimate_tokens from runtime.engine import ExecutionResult from runtime.python_sandbox import digest, workspace_file from runtime.result_models import PlatformJudgment, PlatformResultPayload logger = get_logger(__name__) VERSION = 'main-fan-workflow-v5' LOCAL_ZONE = ZoneInfo('Asia/Shanghai') JUDGMENT_POLICY = { 'version': 'main-fan-precision-first-v2-monitoring-rules', 'priority': 'precision', 'monitoring': '固定代码按本轮冻结规则生成monitoring_assessment;已确认的监测告警不能被模型降级,不增加稳定窗口等新条件。', 'performance': '辅助性粗略核对;只有清晰、可比且确凿的异常证据才告警,识别不清或资料不足只说明限制。', 'normal': '有可用数据且未发现确凿异常;不表示未核实项目已被证明合格或安全。', 'undetermined': '仅当报告、开停、风量三类均没有可用数据。', } class WorkflowJudgment(PlatformJudgment): # 让请求 schema 与本地约束一致;本地文件没有公开证据链接。 evidenceLink: Literal[''] = Field(...) class WorkflowDecision(BaseModel): """模型填写业务内容;平台身份、任务标识和完成时刻由宿主填写。""" model_config = ConfigDict(extra='forbid', strict=True) objectDetail: str = Field(min_length=1) dataDetail: str judgment: WorkflowJudgment @field_validator('objectDetail') @classmethod def nonblank_object(cls, value): if not value.strip(): raise ValueError('研判对象不能为空白') return value @field_validator('judgment') @classmethod def complete_judgment(cls, value): if not value.result.strip() or not value.reason.strip(): raise ValueError('研判摘要和正文不能为空白') if value.evidenceLink: raise ValueError('本地工作流没有公开证据页,evidenceLink 须为空字符串') return value class WorkflowError(ValueError): def __init__(self, code, message, *, details=None): self.code = code self.details = details or {} super().__init__(message) def publish_enabled(): # 新工作流默认用于服务器本地核验;不能因为沿用旧环境的总开关而直接上报。 return (config.get_bool('MAIN_FAN_WORKFLOW_PUBLISH_ENABLED', False) and config.get_bool('RESULT_PLATFORM_ENABLED', False)) def qwen_http_client(): return httpx.AsyncClient(trust_env=False) def full_monitoring(context, user, session): prepared = context['prepared_data'] compact = exploration._read_json(workspace_file(user, session, prepared['agent_data_path'])) details_path = compact.get('details_path') if details_path: if details_path not in context.get('query_exports', {}): raise ValueError('监测详情不属于本轮已核验输入') details = exploration._read_json(workspace_file(user, session, details_path)) # 不重新取数或计算切换/比例,只展开固定预处理已经产生的全部结果。 view = history_compaction.agent_view(details, details_path, full=True) compact = history_processing.agent_input(view) compact['state_period_semantics'] = view['state_period_semantics'] if compact.get('switch_candidates_truncated') or any(row.get('truncated') for row in compact['states']): raise ValueError('工作流需要完整监测结果,不能将截断预览交给唯一一次模型调用') return compact def build_input(workflow, monitoring, reports, scope, *, availability=None, monitoring_assessment=None): context = workflow['context'] availability = data_availability(reports, monitoring) if availability is None else availability monitoring_assessment = (workflow_monitoring.assess(monitoring, context['policy']) if monitoring_assessment is None else monitoring_assessment) references = [{'id': row['id'], 'from_fan': row['from_fan'], 'to_fan': row['to_fan'], 'flow': row.get('flow', []), 'comparison': row.get('comparison', {})} for row in monitoring['switch_candidates']] return {'workflow_version': VERSION, 'workflow_id': workflow['id'], 'identity': {'mine_code': scope.mine['mine_id'], 'mine_name': scope.mine['mine_name'], 'dept_id': scope.mine['dept_id'], 'shaft': scope.shaft.model_dump(mode='json'), 'fan_pair': context['task_input'].get('fan_pair', [])}, 'rules': context['policy'], 'judgment_policy': JUDGMENT_POLICY, 'data_availability': availability, 'monitoring_assessment': monitoring_assessment, 'pdf': reports, 'airflow': {'range': monitoring['range'], 'flows': monitoring['flows'], 'switch_references': references, 'notes': monitoring.get('notes', [])}, 'switching': {'range': monitoring['range'], 'states': monitoring['states'], 'candidates': [{**row, 'duration_minutes': row.get('duration_minutes'), 'duration_status': row.get('duration_status') or ( 'unknown' if row.get('duration_minutes') is None else 'provided')} for row in monitoring['switch_candidates']], 'time_range_semantics': '整个候选的采样定位范围,不用起止相减;直接采用duration_minutes及其采样上下界。' 'estimated表示按实际开停变化观测估算,只有无法配对时间时才为unknown。', 'notes': monitoring.get('notes', []), 'state_period_semantics': monitoring.get('state_period_semantics', '')}, 'history_coverage': context.get('history_manifest', {}) and { key: context['history_manifest'][key] for key in ('cutoff', 'lookback_days', 'point_count', 'expected_segments', 'completed_segments', 'failed_segments', 'coverage_complete')}, 'output_schema': decision_schema(availability, monitoring_assessment)} def decision_schema(availability=None, monitoring_assessment=None): schema = WorkflowDecision.model_json_schema() if availability is not None: schema['$defs']['WorkflowJudgment']['properties']['resultCode']['enum'] = ( ['NORMAL', 'WARNING', 'ABNORMAL'] if availability['any_available'] else ['UNDETERMINED']) if monitoring_assessment and monitoring_assessment['minimum_result_code'] != 'NORMAL': minimum = workflow_monitoring.RANK[monitoring_assessment['minimum_result_code']] schema['$defs']['WorkflowJudgment']['properties']['resultCode']['enum'] = [ code for code, rank in workflow_monitoring.RANK.items() if rank >= minimum] return schema def response_format(availability=None, monitoring_assessment=None): return {'type': 'json_schema', 'json_schema': {'name': 'main_fan_workflow_decision', 'strict': True, 'schema': decision_schema(availability, monitoring_assessment)}} def data_availability(reports, monitoring): def finite(value): return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) readable_pages = sum(page['status'] == 'completed' and bool(page.get('text', '').strip()) for report in reports if not report.get('is_mock') for page in report['pages']) known_states = {'开', '停', '开机', '停机', '运行', '停止', 'running', 'stopped', 'on', 'off'} state_periods = sum(str(period.get('state', '')).strip().lower() in known_states for point in monitoring.get('states', []) for period in point.get('periods', [])) candidates = monitoring.get('switch_candidates', []) durations = sum(finite(row.get('duration_minutes')) and row['duration_minutes'] >= 0 for row in candidates) samples = sum(max(0, row['samples']) for row in monitoring.get('flows', []) if finite(row.get('samples'))) values = sum(finite(value) for point in monitoring.get('flows', []) for value in point.get('range', [])) values += sum(finite(row.get('comparison', {}).get(key)) for row in candidates for key in ('before_value', 'after_value', 'change_percent')) values += sum(isinstance(sample, (list, tuple)) and len(sample) == 2 and finite(sample[1]) for row in candidates for point in row.get('flow', []) for sample in (point.get('before'), point.get('after'))) comparisons = sum(all(finite(row.get('comparison', {}).get(key)) for key in ('before_value', 'after_value', 'change_percent')) for row in candidates) return {'pdf_readable_pages': readable_pages, 'known_state_periods': state_periods, 'known_switch_intervals': durations, 'flow_numeric_samples': samples, 'flow_numeric_values': values, 'flow_comparisons': comparisons, 'any_available': bool(readable_pages or state_periods or durations or samples or values)} def validate_result_policy(decision, availability): undetermined = decision.judgment.resultCode == 'UNDETERMINED' if undetermined != (not availability['any_available']): raise WorkflowError('workflow_result_policy', 'UNDETERMINED 仅适用于三类数据均不可用;有可用数据时按明确异常证据判定,资料局部缺失只说明限制', details={'data_availability': availability, 'result_code': decision.judgment.resultCode}) def parse_decision(content, *, source_name='qwen-response.json'): if not isinstance(content, str) or not content.strip(): raise WorkflowError('qwen_empty_response', 'Qwen 没有返回研判 JSON,原始响应已保存') value = workflow_json_repair.unfenced(content) def invalid_number(number): raise ValueError('研判 JSON 不接受非有限数值:' + number) try: raw = json.loads(value, parse_constant=invalid_number) except json.JSONDecodeError as exc: raise WorkflowError('qwen_invalid_json', f'Qwen JSON 语法错误:{exc.msg},第 {exc.lineno} 行第 {exc.colno} 列(偏移 {exc.pos});原文见 {source_name}', details={'error_type': 'JSONDecodeError', 'line': exc.lineno, 'column': exc.colno, 'offset': exc.pos, 'message': exc.msg}) from exc except ValueError as exc: raise WorkflowError('qwen_invalid_json', str(exc), details={'error_type': type(exc).__name__}) from exc try: return WorkflowDecision.model_validate(raw) except ValidationError as exc: # 不把模型正文或整份输入塞入日志,只记录字段位置和约束错误。 errors = exc.errors(include_input=False, include_context=False, include_url=False) raise WorkflowError('qwen_schema_validation', f'Qwen JSON 可解析,但字段不符合研判契约;字段错误见 diagnostics.error_details,原文见 {source_name}', details={'error_type': 'ValidationError', 'errors': errors[:8]}) from exc def build_payload(decision, workflow, scope): binding = load_extensions().business('main_fan').result_binding task_id = workflow['context']['job_id'] or workflow['id'] return PlatformResultPayload(agentCode=binding.agent_code, agentName=binding.agent_name, taskId=task_id, checkedAt=datetime.now(LOCAL_ZONE).strftime('%Y-%m-%d %H:%M:%S'), mineCode=scope.mine['mine_id'], mineName=scope.mine['mine_name'], **decision.model_dump(mode='json')).model_dump(mode='json') async def execute(*, workflow_id, user_name, session_id, progress=None, qwen_client=None, ocr_client=None): """一次业务研判;仅失败扫描页补识别和不合格 JSON 格式修复允许额外调用。""" scope = scope_for(user_name, session_id) workflow = exploration._load(workflow_id, user_name, session_id, allow_completed=True) if workflow['status'] == 'completed': if (workflow.get('result') or {}).get('assessment', {}).get('mode') != VERSION: raise ValueError('旧执行版本结果不能冒充本版工作流结果,请重新研判') result = workflow['result'] diagnostics = {**result['assessment'].get('diagnostics', {}), 'reused_result': True, 'qwen_calls_this_attempt': 0} return ExecutionResult(workflow['context'].get('assessment_workflow', {}).get('run_id', ''), session_id, 'success', result['result'], '', {}, result.get('sources', []), [], result, diagnostics) context = workflow['context'] admission = None run_id = uuid4().hex started = time.monotonic() prefix = '/workspace/exploration/' + workflow_id + '/workflow' meters = {} diagnostics = {'execution_mode': VERSION, 'qwen_calls': 0, 'judgment_calls': 0, 'qwen_ocr_calls': 0, 'json_repair_calls': 0, 'tool_calls': 0} artifacts = {} stage_name = '初始化工作流' def write(name, value): virtual = prefix + '/' + name path = workspace_file(user_name, session_id, virtual) exploration._write(path, value) context.setdefault('query_exports', {})[virtual] = digest(path) artifacts[name] = virtual return virtual def stage(name, status, result=None): nonlocal stage_name stage_name = name rows = workflow['stages'] key = 'workflow:' + name found = next((row for row in rows if row['name'] == key), None) value = {'name': key, 'status': status, 'result': result} if found is None: rows.append(value) else: found.clear(); found.update(value) context['assessment_workflow'] = {'version': VERSION, 'run_id': run_id, 'stage': name, 'status': status, 'diagnostics': diagnostics, 'files': artifacts} main_fan_store.update_workflow(workflow_id, context=context, stages=rows) if progress: progress({'stage': name, 'status': status, 'workflow_id': workflow_id}) logger.info('研判工作流阶段', extra={'kv': {'event': 'workflow.stage', 'stage': name, 'status': status, 'workflow_id': workflow_id, 'run_id': run_id}}) def record_usage(model, raw): meter = meters.setdefault(model, UsageMeter(user_name, session_id, 'business', model, trace_id=get_trace_id(), usage_id=run_id if model == config.MODEL else None)) if raw: meter.add(raw.get('prompt_tokens', 0), raw.get('completion_tokens', 0)) def record_qwen_call(kind): diagnostics['qwen_calls'] += 1 diagnostics[kind] += 1 def usage(): rows = {name: {'input_tokens': meter.input_tokens, 'output_tokens': meter.output_tokens, 'total_tokens': meter.total_tokens, 'usage_source': meter.usage_source} for name, meter in meters.items()} return {'input_tokens': sum(row['input_tokens'] for row in rows.values()), 'output_tokens': sum(row['output_tokens'] for row in rows.values()), 'total_tokens': sum(row['total_tokens'] for row in rows.values()), 'models': rows} with log_context(run_id=run_id, workflow_id=workflow_id, execution_mode=VERSION): try: admission = await execution.admit(user_name, session_id) admission.business_lease = business.BusinessLease('main_fan') admission.business_lease.bind() await asyncio.to_thread(business_store.bind_session, session_id, 'main_fan') exploration.validate_inputs(workflow, user_name, session_id) if not _is_qwen35_model(config.MODEL): raise WorkflowError('workflow_model_config', '本工作流请将 DEEPAGENT_MODEL 配置为实际 Qwen3.5 服务模型名') endpoint = service_url(config.OPENAI_BASE_URL) maximum = config.get_int('MAIN_FAN_WORKFLOW_MAX_TOKENS', 16384) timeout = config.get_int('MAIN_FAN_WORKFLOW_TIMEOUT_SECONDS', 600) repair_limit = config.get_int('MAIN_FAN_JSON_REPAIR_MAX_ATTEMPTS', workflow_json_repair.MAX_ATTEMPTS) if maximum <= 0 or timeout <= 0: raise ValueError('工作流输出长度及超时必须大于 0') if not 0 <= repair_limit <= workflow_json_repair.MAX_ATTEMPTS: raise ValueError('JSON 格式修复次数须为 0~3,不能超过 3 次') ocr_settings = OCRSettings.from_config() stage('PDF逐页提取', 'running') extractor = PDFExtractor(user_name, session_id, prefix, scope.cache_root, settings=ocr_settings, client=ocr_client, qwen_client=qwen_client, progress=progress, cache_policy=context['task_input'].get('document_cache_policy', 'auto'), on_usage=lambda raw: record_usage(ocr_settings.model, raw), on_qwen_usage=lambda raw: record_usage(config.MODEL, raw), on_qwen_call=lambda: record_qwen_call('qwen_ocr_calls')) materials = context['prepared_data']['reports']['reports'] reports = await extractor.extract(materials) context.setdefault('query_exports', {}).update(extractor.files) write('pdf-extraction.json', reports) diagnostics.update(pdf_count=len(reports), pdf_pages=sum(row['page_count'] for row in reports), failed_pages=sum(page['status'] != 'completed' for row in reports for page in row['pages'])) stage('PDF逐页提取', 'completed', {key: diagnostics[key] for key in ('pdf_count', 'pdf_pages', 'failed_pages')}) stage('过滤明确无关PDF页', 'running') selected_reports, selection = select_pages(reports) write('pdf-page-selection.json', selection) diagnostics.update(retained_pdf_pages=sum(len(row['pages']) for row in selected_reports), excluded_pdf_pages=sum(len(row['excluded_pages']) for row in selection)) stage('过滤明确无关PDF页', 'completed', {key: diagnostics[key] for key in ('retained_pdf_pages', 'excluded_pdf_pages')}) stage('合并本轮三类输入', 'running') monitoring = full_monitoring(context, user_name, session_id) availability = data_availability(reports, monitoring) stage('监测阈值固定判定', 'running') monitoring_assessment = workflow_monitoring.assess(monitoring, context['policy']) write('monitoring-assessment.json', monitoring_assessment) diagnostics['monitoring_result_code'] = monitoring_assessment['minimum_result_code'] stage('监测阈值固定判定', 'completed') inputs = build_input(workflow, monitoring, selected_reports, scope, availability=availability, monitoring_assessment=monitoring_assessment) diagnostics['data_availability'] = availability diagnostics['judgment_policy'] = JUDGMENT_POLICY['version'] write('qwen-input.json', inputs) system_prompt = Path(__file__).with_name('workflow_prompt.md').read_text(encoding='utf-8') messages = [{'role': 'system', 'content': system_prompt}, {'role': 'user', 'content': json.dumps(inputs, ensure_ascii=False, allow_nan=False)}] payload = {'model': config.MODEL, 'messages': messages, 'stream': False, 'max_tokens': maximum, 'response_format': response_format(availability, monitoring_assessment), **_qwen_thinking_payload(normalize_thinking(None))} stage('合并本轮三类输入', 'completed') headers = {'Authorization': 'Bearer ' + config.OPENAI_API_KEY} if config.OPENAI_API_KEY else {} async def invoke(client, request, label, kind): # 无 HTTP 自动重试;补识别、格式修复均有明确计数和独立原始响应。 write(label + '-request.json', request) record_qwen_call(kind) response = await client.post(endpoint + '/chat/completions', json=request, headers=headers, timeout=timeout) name = label + '-response.json' virtual = prefix + '/' + name local = workspace_file(user_name, session_id, virtual) local.parent.mkdir(parents=True, exist_ok=True) local.write_text(response.text, encoding='utf-8') context['query_exports'][virtual] = digest(local) artifacts[name] = virtual diagnostics['qwen_http_status'] = response.status_code if response.is_error: raise WorkflowError('qwen_http_error', f'Qwen HTTP {response.status_code},原始错误见 {name}') raw = response.json() if not isinstance(raw, dict): raise WorkflowError('qwen_unexpected_response', f'Qwen 响应结构无效,详见 {name}') choice = raw['choices'][0] if not isinstance(choice, dict): raise WorkflowError('qwen_unexpected_response', f'Qwen 候选消息结构无效,详见 {name}') message = choice['message'] if not isinstance(message, dict): raise WorkflowError('qwen_unexpected_response', f'Qwen 消息结构无效,详见 {name}') content = message.get('content') if raw.get('usage'): record_usage(config.MODEL, raw['usage']) else: record_usage(config.MODEL, {}) meters[config.MODEL].add(estimate_tokens(json.dumps(request['messages'], ensure_ascii=False)), estimate_tokens((content if isinstance(content, str) else '') + str(message.get('reasoning') or message.get('reasoning_content') or '')), 'estimate') if kind == 'judgment_calls': diagnostics.update(response_id=raw.get('id'), finish_reason=choice.get('finish_reason')) if choice.get('finish_reason') == 'length': raise WorkflowError('qwen_response_truncated', f'Qwen 输出达到长度上限,不能补造丢失内容;详见 {name}') if message.get('tool_calls') or choice.get('finish_reason') not in {'stop', None}: raise WorkflowError('qwen_unexpected_response', f'Qwen 未返回正常结束的正文,详见 {name}') return raw, content async def judge(client): stage('Qwen一次研判', 'running') original_raw, original = await invoke(client, payload, 'qwen', 'judgment_calls') stage('Qwen一次研判', 'completed', {'response_id': original_raw.get('id'), 'usage': original_raw.get('usage')}) stage('JSON校验与格式修复', 'running') attempts = [] write('json-repair-history.json', attempts) try: decision = parse_decision(original) except WorkflowError as exc: if not repair_limit or not workflow_json_repair.can_repair(original, exc.code): raise error = {'error_code': exc.code, 'message': str(exc), 'details': exc.details} else: write('qwen-final-response.json', original_raw) return decision current = original for attempt in range(1, repair_limit + 1): label = f'qwen-repair-{attempt}' entry = {'attempt': attempt, 'request': prefix + '/' + label + '-request.json', 'response': prefix + '/' + label + '-response.json', 'status': 'running'} attempts.append(entry) write('json-repair-history.json', attempts) logger.warning('研判 JSON 未通过校验,进行格式修复', extra={'kv': { 'event': 'workflow.json.repair.started', 'attempt': attempt, 'max_attempts': repair_limit, 'error_code': error['error_code'], 'error_details': error['details']}}) if progress: progress({'stage': 'json_format_repair', 'attempt': attempt, 'max_attempts': repair_limit}) repair_request = {'model': config.MODEL, 'stream': False, 'max_tokens': maximum, # 格式修复保留原编码;随后由固定监测规则统一合并,修复器不能改业务内容。 'messages': workflow_json_repair.messages(current, decision_schema(availability), error), 'response_format': response_format(availability), **_qwen_thinking_payload(normalize_thinking(None))} repaired = None try: repaired_raw, repaired = await invoke(client, repair_request, label, 'json_repair_calls') candidate = parse_decision(repaired, source_name=label + '-response.json') changed = workflow_json_repair.preservation_errors(original, candidate) if changed: current = original raise WorkflowError('qwen_repair_content_changed', '格式修复改动了业务内容,不能接受', details={'changed_content': changed}) except (WorkflowError, httpx.HTTPError, ValueError, KeyError, IndexError, TypeError) as exc: error = {'error_code': exc.code if isinstance(exc, WorkflowError) else type(exc).__name__, 'message': str(exc)[:500], 'details': getattr(exc, 'details', {})} entry.update(status='failed', error=error) # 继续修复上一轮字符串;最终始终与最初研判核对内容。 if isinstance(exc, WorkflowError) and exc.code in {'qwen_invalid_json', 'qwen_schema_validation'}: if isinstance(repaired, str): current = repaired write('json-repair-history.json', attempts) continue entry.update(status='completed', response_id=repaired_raw.get('id')) write('json-repair-history.json', attempts) write('qwen-final-response.json', repaired_raw) diagnostics['json_repaired'] = True return candidate raise WorkflowError('qwen_json_repair_exhausted', f'JSON 格式修复 {repair_limit} 次仍未通过,原始响应及每次修复记录已保存', details={'repair_attempts': repair_limit, 'last_error': error}) if qwen_client is None: async with qwen_http_client() as client: decision = await judge(client) else: decision = await judge(qwen_client) stage('JSON校验与格式修复', 'completed', {'repair_attempts': diagnostics['json_repair_calls']}) stage('校验并保存平台JSON', 'running') resolved, resolution = workflow_monitoring.resolve(decision.model_dump(mode='json'), monitoring_assessment) decision = WorkflowDecision.model_validate(resolved) write('decision-resolution.json', resolution) diagnostics['monitoring_enforced'] = resolution['monitoring_enforced'] diagnostics['model_result_code'] = resolution['model_result_code'] if resolution['monitoring_enforced']: logger.info('固定监测规则生成正式告警结论', extra={'kv': { 'event': 'workflow.monitoring.enforced', 'model_result_code': resolution['model_result_code'], 'minimum_result_code': resolution['minimum_result_code'], 'final_result_code': resolution['final_result_code']}}) validate_result_policy(decision, availability) exploration.validate_inputs(workflow, user_name, session_id) platform = build_payload(decision, workflow, scope) write('platform-payload.json', platform) write('usage.json', usage()) body = '# ' + scope.mine['mine_name'] + ' 主备风机研判报告\n\n' + decision.judgment.result + '\n\n' + decision.judgment.reason report_virtual = prefix + '/report.md' report_path = workspace_file(user_name, session_id, report_virtual) report_path.write_text(body, encoding='utf-8') context['query_exports'][report_virtual] = digest(report_path) artifacts['report.md'] = report_virtual sources = [{'title': row.get('filename') or Path(row['path']).name, 'url': row['path'], 'type': 'file'} for row in materials] sources.append({'title': '本轮完整工作流输入', 'url': artifacts['qwen-input.json'], 'type': 'file'}) value = {'status': 'completed', 'mine_code': scope.mine['mine_id'], 'mine_name': scope.mine['mine_name'], 'check_object': decision.objectDetail, 'result_title': scope.mine['mine_name'] + ' 主备风机研判报告', 'result': body, 'result_format': 'report', 'workflow_id': workflow_id, 'sources': sources, 'check_process': '固定监测预处理 → Python/GLM OCR及Qwen补识别 → 无关页过滤 → 固定监测阈值判定 → Qwen一次研判 → JSON校验及最多3次格式修复 → 固定告警结论合并 → 本地归档', 'platform_payload': platform, 'analysis': None, 'assessment': {'mode': VERSION, 'decision': {'result_code': decision.judgment.resultCode, 'summary': decision.judgment.result}, 'monitoring_assessment': monitoring_assessment, 'history_coverage': inputs['history_coverage'], 'report_archives': [{key: row[key] for key in ('path', 'sha256', 'is_mock', 'binding', 'page_count', 'complete', 'reused')} for row in reports], 'workflow_files': artifacts, 'diagnostics': diagnostics}, 'artifacts': [{'filename': name, 'path': artifacts[name]} for name in ( 'report.md', 'platform-payload.json', 'qwen-input.json', 'pdf-extraction.json', 'pdf-page-selection.json', 'qwen-response.json', 'qwen-final-response.json', 'json-repair-history.json', 'monitoring-assessment.json', 'decision-resolution.json') if name in artifacts]} with db_conn.transaction(): saved = main_fan_store.save_analysis(user_name, session_id, value) result = MainFanResult.model_validate(saved).model_dump(mode='json') stage('校验并保存平台JSON', 'completed', {'analysis_id': saved['analysis_id']}) main_fan_store.update_workflow(workflow_id, context=context, stages=workflow['stages'], result=result, status='completed') try: await asyncio.to_thread(chat_store.save_message, session_id, 'user', '执行主通风机固定工作流(本轮数据独立研判)') await asyncio.to_thread(chat_store.save_message, session_id, 'assistant', body, citations=sources) except Exception: logger.warning('工作流结果已归档,但会话展示消息保存失败', exc_info=True) logger.info('工作流研判已完成并保存', extra={'kv': {'event': 'workflow.completed', 'result_code': decision.judgment.resultCode, 'qwen_calls': diagnostics['qwen_calls'], 'duration_ms': round((time.monotonic() - started) * 1000)}}) return ExecutionResult(run_id, session_id, 'success', body, '', usage(), sources, [], result, diagnostics) except asyncio.CancelledError: main_fan_store.update_workflow(workflow_id, context=context, stages=workflow['stages'], status='interrupted') raise except Exception as exc: code = exc.code if isinstance(exc, WorkflowError) else type(exc).__name__ diagnostics['error'] = str(exc)[:500] if isinstance(exc, WorkflowError) and exc.details: diagnostics['error_details'] = exc.details stage(stage_name, 'failed', {'error_code': code, 'message': str(exc)[:500], 'error_details': diagnostics.get('error_details', {})}) write('failure.json', {'error_code': code, 'message': str(exc)[:500], 'diagnostics': diagnostics}) main_fan_store.update_workflow(workflow_id, context=context, stages=workflow['stages'], status='failed') logger.error('工作流研判失败', extra={'kv': {'event': 'workflow.failed', 'error_code': code, 'stage': stage_name, 'qwen_calls': diagnostics['qwen_calls'], 'message': str(exc)[:500], 'error_details': diagnostics.get('error_details', {})}}) return ExecutionResult(run_id, session_id, 'failed', '', code, usage(), [], [], None, diagnostics) finally: try: for meter in meters.values(): await asyncio.shield(asyncio.to_thread(meter.settle)) finally: if admission is not None: admission.release()