| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296 |
- """SSE over POST with bounded workers, buffering and per-session exclusion.
- Run ONE process per Redis session namespace. A disconnected request retains its
- slot and session exclusion until the synchronous graph worker actually exits.
- """
- import asyncio
- import json
- import logging
- import re
- import time
- from concurrent.futures import ThreadPoolExecutor
- from contextlib import asynccontextmanager
- from queue import Empty, Full, Queue
- from threading import Event, Lock
- from uuid import uuid4
- from starlette.applications import Starlette
- from starlette.requests import ClientDisconnect
- from starlette.responses import JSONResponse, StreamingResponse
- from starlette.routing import Route
- from .config import load_settings
- from .runtime import Runtime
- from .company_classification import ClassificationService, classify_company
- from .clock import shanghai_now
- from .logging import configure_logging, log, safe_code, session_ref, summary_diagnostics, candidate_counts, validation_counts
- from .progress import STAGES, progress
- from .attachments import AttachmentError, resolve_arrays
- from .memory import Admission
- from src.step3_question_answer.timing import Timings, bind
- def sse(event, data):
- return f'event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n'
- class Job:
- def __init__(self, thread_id, timeout, request_id=None, received_at=None, received_monotonic=None):
- self.thread_id = thread_id
- self.request_id = request_id or uuid4().hex
- self.received_at = received_at or shanghai_now()
- self.session_ref = session_ref(thread_id)
- self.outcome = 'running'
- self.events = Queue(maxsize=8)
- self.stopped = Event()
- self.finished = Event()
- self.started_at = time.monotonic()
- self.timings = Timings(self.started_at if received_monotonic is None else received_monotonic)
- self.deadline = self.started_at + timeout
- def emit(self, event, data):
- if self.stopped.is_set():
- return
- if event == 'progress' and data.get('stage') in STAGES and data.get('status') in ('running', 'completed', 'waiting_input', 'failed'):
- info = progress(data['stage'], data['status'])
- self.log('stage', info.pop('message'), **info)
- elif event == 'error':
- self.outcome = 'failed'
- self.log('request_failed', '本轮处理失败', level=logging.ERROR, code=safe_code(data.get('code')))
- elif event == 'result':
- recommendation = data.get('recommendation') or {}
- errors = data.get('errors') or []
- analysis_errors = recommendation.get('analysis_errors') or []
- summary_status = (recommendation.get('summary') or {}).get('status')
- highlight_errors = recommendation.get('support_highlight_errors') or []
- degraded = bool(errors or analysis_errors or highlight_errors or recommendation.get('complete') is False or summary_status == 'fallback')
- if degraded:
- self.outcome = 'degraded'
- self.log('result_ready', '本轮结果已生成', level=logging.WARNING if degraded else logging.INFO,
- degraded=degraded, error_count=len(errors), analysis_error_count=len(analysis_errors),
- candidate_outcome_counts=candidate_counts(recommendation.get('candidate_diagnostics')),
- support_highlight_error_codes=[safe_code(e.get('code')) for e in highlight_errors[:16]],
- validation_error_counts=validation_counts(recommendation.get('candidate_diagnostics')),
- error_codes=[safe_code(c) for c in errors[:16]],
- analysis_error_codes=[safe_code(e.get('code')) for e in analysis_errors[:16]],
- summary_error_code=safe_code((recommendation.get('summary') or {}).get('error')) if (recommendation.get('summary') or {}).get('error') else None,
- summary_diagnostics=summary_diagnostics((recommendation.get('summary') or {}).get('diagnostics')),
- planning_error_code=safe_code((recommendation.get('search_plan') or {}).get('error')) if (recommendation.get('search_plan') or {}).get('error') else None,
- summary_status=summary_status if summary_status in ('generated', 'fallback', 'no_recommendations') else None)
- elif event == 'done':
- if data.get('status') == 'needs_input':
- self.outcome = 'needs_input'
- elif self.outcome == 'running':
- self.outcome = 'completed'
- frame = sse(event, {'thread_id': self.thread_id, 'request_id': self.request_id, 'data': data})
- enqueue_started = self.timings.clock()
- while not self.stopped.is_set():
- if time.monotonic() >= self.deadline:
- self.stopped.set()
- return
- try:
- self.events.put(frame, timeout=0.1)
- self.timings.add('event_queue', self.timings.clock() - enqueue_started)
- if event == 'item':
- self.timings.mark('first_card')
- elif event in ('answer_delta', 'answer', 'summary') and isinstance(data.get('text'), str) and data['text'].strip():
- self.timings.mark('first_text')
- elif event == 'result':
- self.timings.mark('result_ready')
- elif event in ('done', 'error'):
- self.timings.mark('terminal_ready')
- return
- except Full:
- continue
- def log(self, event, message, **fields):
- log(event, message, request_id=self.request_id, session_ref=self.session_ref,
- elapsed_ms=round((time.monotonic() - self.started_at) * 1000), **fields)
- class Service:
- def __init__(self, runtime, settings):
- self.runtime, self.settings = runtime, settings
- self.admission = getattr(runtime, 'admission', None) or Admission(settings)
- self.pool = ThreadPoolExecutor(max_workers=settings.max_concurrent, thread_name_prefix='chat')
- self.lock = Lock()
- self.active = {}
- self.closing = False
- def submit(self, thread_id, question, request_id=None, received_at=None, pictures=None, files=None, received_monotonic=None):
- with self.lock:
- if thread_id in self.active:
- return None, 409, 'thread_busy'
- if self.closing or len(self.active) >= self.settings.max_concurrent:
- return None, 429, 'server_busy'
- lease, error = self.admission.acquire()
- if error:
- return None, 503 if error == 'memory_status_unavailable' else 429, error
- try:
- job = Job(thread_id, self.settings.stream_timeout_seconds, request_id, received_at, received_monotonic)
- job.memory_lease = lease
- self.active[thread_id] = job
- job.log('request_accepted', '请求已接纳', active_requests=len(self.active))
- self.pool.submit(self._run, job, question, pictures or [], files or [])
- except Exception:
- self.active.pop(thread_id, None)
- lease.release()
- return None, 503, 'service_unavailable'
- return job, 200, None
- def _run(self, job, question, pictures=None, files=None):
- job.timings.add('worker_queue', job.timings.clock() - job.started_at)
- job.timings.mark('worker_started')
- job.log('worker_started', '开始处理问题')
- try:
- extra = {'pictures': pictures, 'files': files} if pictures or files else {}
- with bind(job.timings):
- self.runtime.run(job.thread_id, question, job.emit, job.stopped,
- request_id=job.request_id, received_at=job.received_at, **extra)
- except Exception:
- job.emit('error', {'code': 'processing_failed', 'message': '处理失败,请稍后重试或新建会话。'})
- finally:
- # Never free the lock merely because the HTTP caller disconnected.
- with self.lock:
- self.active.pop(job.thread_id, None)
- job.memory_lease.release()
- job.log('request_performance', '本轮耗时统计', metrics=job.timings.snapshot(),
- thread_id=job.thread_id,
- outcome=job.outcome if job.outcome != 'running' else 'stopped' if job.stopped.is_set() else 'incomplete')
- job.log('worker_finished', '后台处理结束,会话锁已释放',
- outcome=job.outcome if job.outcome != 'running' else 'stopped' if job.stopped.is_set() else 'incomplete')
- job.finished.set()
- async def stream(self, job):
- heartbeat = time.monotonic()
- reason = 'client_disconnected'
- try:
- yield sse('accepted', {'thread_id': job.thread_id, 'request_id': job.request_id,
- 'data': {'message': '收到,我先了解一下您的问题。'}})
- while True:
- if time.monotonic() >= job.deadline:
- reason = 'timeout'
- job.log('stream_timeout', '流响应超时', level=logging.WARNING, code='stream_timeout')
- yield sse('error', {'thread_id': job.thread_id, 'request_id': job.request_id,
- 'data': {'code': 'stream_timeout'}})
- return
- try:
- frame = job.events.get_nowait()
- except Empty:
- if job.finished.is_set():
- if reason != 'terminal_event':
- reason = 'worker_ended_without_terminal'
- return
- if time.monotonic() - heartbeat >= self.settings.heartbeat_seconds:
- yield sse('heartbeat', {'thread_id': job.thread_id, 'request_id': job.request_id,
- 'data': {'status': 'processing',
- 'message': '还在为您准备答复,请再稍等一会儿。',
- 'elapsed_seconds': int(time.monotonic() - job.started_at)}})
- heartbeat = time.monotonic()
- await asyncio.sleep(0.02)
- else:
- if frame.startswith(('event: done\n', 'event: error\n')):
- reason = 'terminal_event'
- yield frame
- finally:
- job.stopped.set()
- job.log('stream_closed', '流连接结束', reason=reason, worker_finished=job.finished.is_set())
- def close(self):
- with self.lock:
- self.closing = True
- for job in self.active.values():
- job.stopped.set()
- self.pool.shutdown(wait=True)
- self.runtime.close()
- def create_app(settings=None, runtime_factory=Runtime):
- settings = settings or load_settings()
- @asynccontextmanager
- async def lifespan(app):
- configure_logging()
- log('service_starting', '正在初始化问答服务')
- try:
- runtime = await asyncio.to_thread(runtime_factory, settings)
- except Exception:
- log('service_startup_failed', '问答服务初始化失败', level=logging.ERROR)
- raise
- app.state.service = Service(runtime, settings)
- classify = getattr(runtime, 'classify_company', None)
- app.state.classification_service = ClassificationService(
- classify, settings.max_concurrent, app.state.service.admission) if classify else None
- app.state.classification_timeout = settings.stream_timeout_seconds
- log('service_ready', '问答服务已就绪', max_concurrent_requests=settings.max_concurrent)
- try:
- yield
- finally:
- log('service_stopping', '正在停止问答服务')
- if app.state.classification_service is not None:
- await asyncio.to_thread(app.state.classification_service.close)
- await asyncio.to_thread(app.state.service.close)
- log('service_stopped', '问答服务已停止')
- async def chat(request):
- request_id = uuid4().hex
- received_at = time.monotonic()
- received_time = shanghai_now()
- log('request_received', '收到问答请求', request_id=request_id, method='POST', path='/api/chat')
- def reject(code, status, thread_id=None, retry=False, detail=None):
- log('request_rejected', '请求未被接纳', level=logging.WARNING, request_id=request_id,
- session_ref=session_ref(thread_id) if thread_id is not None else None,
- status_code=status, code=code, elapsed_ms=round((time.monotonic() - received_at) * 1000))
- headers = {'X-Request-ID': request_id}
- if retry:
- headers['Retry-After'] = '2'
- return JSONResponse({'error': code, **(detail or {})}, status_code=status, headers=headers)
- if request.headers.get('content-type', '').split(';')[0].strip().lower() != 'application/json':
- return reject('application_json_required', 415)
- try:
- body = bytearray()
- async with asyncio.timeout(15):
- async for chunk in request.stream():
- if len(body) + len(chunk) > 65536:
- return reject('request_too_large', 413)
- body.extend(chunk)
- value = json.loads(body)
- if (not isinstance(value, dict) or not {'thread_id', 'question'} <= set(value)
- or set(value) - {'thread_id', 'question', 'pictures', 'files'}):
- raise ValueError()
- pictures, files = value.get('pictures', []), value.get('files', [])
- pictures, files = resolve_arrays(pictures, files, settings.attachment_base_url)
- thread_id, question = value['thread_id'], value['question']
- if (not isinstance(thread_id, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', thread_id)
- or not isinstance(question, str) or not question.strip() or len(question) > 8000):
- raise ValueError()
- except TimeoutError:
- return reject('request_timeout', 408)
- except AttachmentError as exc:
- kind, index = getattr(exc, 'kind', None), getattr(exc, 'index', None)
- if kind and index:
- title = ('图片' if kind == 'picture' else '附件') + str(index)
- return reject('invalid_request', 422, detail={
- 'message': title + '的链接格式无效,请更换后重新提问。',
- 'attachment': {'kind': kind, 'index': index, 'title': title,
- 'error': 'invalid_attachment_url'}})
- return reject('invalid_request', 422)
- except (ValueError, UnicodeError, ClientDisconnect):
- return reject('invalid_request', 422)
- job, status, error = request.app.state.service.submit(thread_id, question, request_id, received_time,
- pictures=pictures, files=files, received_monotonic=received_at)
- if error:
- return reject(error, status, thread_id, retry=True)
- return StreamingResponse(request.app.state.service.stream(job), media_type='text/event-stream',
- headers={'Cache-Control': 'no-cache, no-transform', 'X-Accel-Buffering': 'no',
- 'X-Request-ID': request_id})
- async def health(request):
- return JSONResponse({'status': 'ready'})
- return Starlette(routes=[Route('/api/chat', chat, methods=['POST']),
- Route('/api/company/classify', classify_company, methods=['POST']),
- Route('/health', health)], lifespan=lifespan)
|