| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539 |
- """主通风机固定工作流:完整 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()
|