app.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. """SSE over POST with bounded workers, buffering and per-session exclusion.
  2. Run ONE process per Redis session namespace. A disconnected request retains its
  3. slot and session exclusion until the synchronous graph worker actually exits.
  4. """
  5. import asyncio
  6. import json
  7. import logging
  8. import re
  9. import time
  10. from concurrent.futures import ThreadPoolExecutor
  11. from contextlib import asynccontextmanager
  12. from queue import Empty, Full, Queue
  13. from threading import Event, Lock
  14. from uuid import uuid4
  15. from starlette.applications import Starlette
  16. from starlette.requests import ClientDisconnect
  17. from starlette.responses import JSONResponse, StreamingResponse
  18. from starlette.routing import Route
  19. from .config import load_settings
  20. from .runtime import Runtime
  21. from .company_classification import ClassificationService, classify_company
  22. from .clock import shanghai_now
  23. from .logging import configure_logging, log, safe_code, session_ref, summary_diagnostics, candidate_counts, validation_counts
  24. from .progress import STAGES, progress
  25. from .attachments import AttachmentError, resolve_arrays
  26. from .memory import Admission
  27. from src.step3_question_answer.timing import Timings, bind
  28. def sse(event, data):
  29. return f'event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n'
  30. class Job:
  31. def __init__(self, thread_id, timeout, request_id=None, received_at=None, received_monotonic=None):
  32. self.thread_id = thread_id
  33. self.request_id = request_id or uuid4().hex
  34. self.received_at = received_at or shanghai_now()
  35. self.session_ref = session_ref(thread_id)
  36. self.outcome = 'running'
  37. self.events = Queue(maxsize=8)
  38. self.stopped = Event()
  39. self.finished = Event()
  40. self.started_at = time.monotonic()
  41. self.timings = Timings(self.started_at if received_monotonic is None else received_monotonic)
  42. self.deadline = self.started_at + timeout
  43. def emit(self, event, data):
  44. if self.stopped.is_set():
  45. return
  46. if event == 'progress' and data.get('stage') in STAGES and data.get('status') in ('running', 'completed', 'waiting_input', 'failed'):
  47. info = progress(data['stage'], data['status'])
  48. self.log('stage', info.pop('message'), **info)
  49. elif event == 'error':
  50. self.outcome = 'failed'
  51. self.log('request_failed', '本轮处理失败', level=logging.ERROR, code=safe_code(data.get('code')))
  52. elif event == 'result':
  53. recommendation = data.get('recommendation') or {}
  54. errors = data.get('errors') or []
  55. analysis_errors = recommendation.get('analysis_errors') or []
  56. summary_status = (recommendation.get('summary') or {}).get('status')
  57. highlight_errors = recommendation.get('support_highlight_errors') or []
  58. degraded = bool(errors or analysis_errors or highlight_errors or recommendation.get('complete') is False or summary_status == 'fallback')
  59. if degraded:
  60. self.outcome = 'degraded'
  61. self.log('result_ready', '本轮结果已生成', level=logging.WARNING if degraded else logging.INFO,
  62. degraded=degraded, error_count=len(errors), analysis_error_count=len(analysis_errors),
  63. candidate_outcome_counts=candidate_counts(recommendation.get('candidate_diagnostics')),
  64. support_highlight_error_codes=[safe_code(e.get('code')) for e in highlight_errors[:16]],
  65. validation_error_counts=validation_counts(recommendation.get('candidate_diagnostics')),
  66. error_codes=[safe_code(c) for c in errors[:16]],
  67. analysis_error_codes=[safe_code(e.get('code')) for e in analysis_errors[:16]],
  68. summary_error_code=safe_code((recommendation.get('summary') or {}).get('error')) if (recommendation.get('summary') or {}).get('error') else None,
  69. summary_diagnostics=summary_diagnostics((recommendation.get('summary') or {}).get('diagnostics')),
  70. planning_error_code=safe_code((recommendation.get('search_plan') or {}).get('error')) if (recommendation.get('search_plan') or {}).get('error') else None,
  71. summary_status=summary_status if summary_status in ('generated', 'fallback', 'no_recommendations') else None)
  72. elif event == 'done':
  73. if data.get('status') == 'needs_input':
  74. self.outcome = 'needs_input'
  75. elif self.outcome == 'running':
  76. self.outcome = 'completed'
  77. frame = sse(event, {'thread_id': self.thread_id, 'request_id': self.request_id, 'data': data})
  78. enqueue_started = self.timings.clock()
  79. while not self.stopped.is_set():
  80. if time.monotonic() >= self.deadline:
  81. self.stopped.set()
  82. return
  83. try:
  84. self.events.put(frame, timeout=0.1)
  85. self.timings.add('event_queue', self.timings.clock() - enqueue_started)
  86. if event == 'item':
  87. self.timings.mark('first_card')
  88. elif event in ('answer_delta', 'answer', 'summary') and isinstance(data.get('text'), str) and data['text'].strip():
  89. self.timings.mark('first_text')
  90. elif event == 'result':
  91. self.timings.mark('result_ready')
  92. elif event in ('done', 'error'):
  93. self.timings.mark('terminal_ready')
  94. return
  95. except Full:
  96. continue
  97. def log(self, event, message, **fields):
  98. log(event, message, request_id=self.request_id, session_ref=self.session_ref,
  99. elapsed_ms=round((time.monotonic() - self.started_at) * 1000), **fields)
  100. class Service:
  101. def __init__(self, runtime, settings):
  102. self.runtime, self.settings = runtime, settings
  103. self.admission = getattr(runtime, 'admission', None) or Admission(settings)
  104. self.pool = ThreadPoolExecutor(max_workers=settings.max_concurrent, thread_name_prefix='chat')
  105. self.lock = Lock()
  106. self.active = {}
  107. self.closing = False
  108. def submit(self, thread_id, question, request_id=None, received_at=None, pictures=None, files=None, received_monotonic=None):
  109. with self.lock:
  110. if thread_id in self.active:
  111. return None, 409, 'thread_busy'
  112. if self.closing or len(self.active) >= self.settings.max_concurrent:
  113. return None, 429, 'server_busy'
  114. lease, error = self.admission.acquire()
  115. if error:
  116. return None, 503 if error == 'memory_status_unavailable' else 429, error
  117. try:
  118. job = Job(thread_id, self.settings.stream_timeout_seconds, request_id, received_at, received_monotonic)
  119. job.memory_lease = lease
  120. self.active[thread_id] = job
  121. job.log('request_accepted', '请求已接纳', active_requests=len(self.active))
  122. self.pool.submit(self._run, job, question, pictures or [], files or [])
  123. except Exception:
  124. self.active.pop(thread_id, None)
  125. lease.release()
  126. return None, 503, 'service_unavailable'
  127. return job, 200, None
  128. def _run(self, job, question, pictures=None, files=None):
  129. job.timings.add('worker_queue', job.timings.clock() - job.started_at)
  130. job.timings.mark('worker_started')
  131. job.log('worker_started', '开始处理问题')
  132. try:
  133. extra = {'pictures': pictures, 'files': files} if pictures or files else {}
  134. with bind(job.timings):
  135. self.runtime.run(job.thread_id, question, job.emit, job.stopped,
  136. request_id=job.request_id, received_at=job.received_at, **extra)
  137. except Exception:
  138. job.emit('error', {'code': 'processing_failed', 'message': '处理失败,请稍后重试或新建会话。'})
  139. finally:
  140. # Never free the lock merely because the HTTP caller disconnected.
  141. with self.lock:
  142. self.active.pop(job.thread_id, None)
  143. job.memory_lease.release()
  144. job.log('request_performance', '本轮耗时统计', metrics=job.timings.snapshot(),
  145. thread_id=job.thread_id,
  146. outcome=job.outcome if job.outcome != 'running' else 'stopped' if job.stopped.is_set() else 'incomplete')
  147. job.log('worker_finished', '后台处理结束,会话锁已释放',
  148. outcome=job.outcome if job.outcome != 'running' else 'stopped' if job.stopped.is_set() else 'incomplete')
  149. job.finished.set()
  150. async def stream(self, job):
  151. heartbeat = time.monotonic()
  152. reason = 'client_disconnected'
  153. try:
  154. yield sse('accepted', {'thread_id': job.thread_id, 'request_id': job.request_id,
  155. 'data': {'message': '收到,我先了解一下您的问题。'}})
  156. while True:
  157. if time.monotonic() >= job.deadline:
  158. reason = 'timeout'
  159. job.log('stream_timeout', '流响应超时', level=logging.WARNING, code='stream_timeout')
  160. yield sse('error', {'thread_id': job.thread_id, 'request_id': job.request_id,
  161. 'data': {'code': 'stream_timeout'}})
  162. return
  163. try:
  164. frame = job.events.get_nowait()
  165. except Empty:
  166. if job.finished.is_set():
  167. if reason != 'terminal_event':
  168. reason = 'worker_ended_without_terminal'
  169. return
  170. if time.monotonic() - heartbeat >= self.settings.heartbeat_seconds:
  171. yield sse('heartbeat', {'thread_id': job.thread_id, 'request_id': job.request_id,
  172. 'data': {'status': 'processing',
  173. 'message': '还在为您准备答复,请再稍等一会儿。',
  174. 'elapsed_seconds': int(time.monotonic() - job.started_at)}})
  175. heartbeat = time.monotonic()
  176. await asyncio.sleep(0.02)
  177. else:
  178. if frame.startswith(('event: done\n', 'event: error\n')):
  179. reason = 'terminal_event'
  180. yield frame
  181. finally:
  182. job.stopped.set()
  183. job.log('stream_closed', '流连接结束', reason=reason, worker_finished=job.finished.is_set())
  184. def close(self):
  185. with self.lock:
  186. self.closing = True
  187. for job in self.active.values():
  188. job.stopped.set()
  189. self.pool.shutdown(wait=True)
  190. self.runtime.close()
  191. def create_app(settings=None, runtime_factory=Runtime):
  192. settings = settings or load_settings()
  193. @asynccontextmanager
  194. async def lifespan(app):
  195. configure_logging()
  196. log('service_starting', '正在初始化问答服务')
  197. try:
  198. runtime = await asyncio.to_thread(runtime_factory, settings)
  199. except Exception:
  200. log('service_startup_failed', '问答服务初始化失败', level=logging.ERROR)
  201. raise
  202. app.state.service = Service(runtime, settings)
  203. classify = getattr(runtime, 'classify_company', None)
  204. app.state.classification_service = ClassificationService(
  205. classify, settings.max_concurrent, app.state.service.admission) if classify else None
  206. app.state.classification_timeout = settings.stream_timeout_seconds
  207. log('service_ready', '问答服务已就绪', max_concurrent_requests=settings.max_concurrent)
  208. try:
  209. yield
  210. finally:
  211. log('service_stopping', '正在停止问答服务')
  212. if app.state.classification_service is not None:
  213. await asyncio.to_thread(app.state.classification_service.close)
  214. await asyncio.to_thread(app.state.service.close)
  215. log('service_stopped', '问答服务已停止')
  216. async def chat(request):
  217. request_id = uuid4().hex
  218. received_at = time.monotonic()
  219. received_time = shanghai_now()
  220. log('request_received', '收到问答请求', request_id=request_id, method='POST', path='/api/chat')
  221. def reject(code, status, thread_id=None, retry=False, detail=None):
  222. log('request_rejected', '请求未被接纳', level=logging.WARNING, request_id=request_id,
  223. session_ref=session_ref(thread_id) if thread_id is not None else None,
  224. status_code=status, code=code, elapsed_ms=round((time.monotonic() - received_at) * 1000))
  225. headers = {'X-Request-ID': request_id}
  226. if retry:
  227. headers['Retry-After'] = '2'
  228. return JSONResponse({'error': code, **(detail or {})}, status_code=status, headers=headers)
  229. if request.headers.get('content-type', '').split(';')[0].strip().lower() != 'application/json':
  230. return reject('application_json_required', 415)
  231. try:
  232. body = bytearray()
  233. async with asyncio.timeout(15):
  234. async for chunk in request.stream():
  235. if len(body) + len(chunk) > 65536:
  236. return reject('request_too_large', 413)
  237. body.extend(chunk)
  238. value = json.loads(body)
  239. if (not isinstance(value, dict) or not {'thread_id', 'question'} <= set(value)
  240. or set(value) - {'thread_id', 'question', 'pictures', 'files'}):
  241. raise ValueError()
  242. pictures, files = value.get('pictures', []), value.get('files', [])
  243. pictures, files = resolve_arrays(pictures, files, settings.attachment_base_url)
  244. thread_id, question = value['thread_id'], value['question']
  245. if (not isinstance(thread_id, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', thread_id)
  246. or not isinstance(question, str) or not question.strip() or len(question) > 8000):
  247. raise ValueError()
  248. except TimeoutError:
  249. return reject('request_timeout', 408)
  250. except AttachmentError as exc:
  251. kind, index = getattr(exc, 'kind', None), getattr(exc, 'index', None)
  252. if kind and index:
  253. title = ('图片' if kind == 'picture' else '附件') + str(index)
  254. return reject('invalid_request', 422, detail={
  255. 'message': title + '的链接格式无效,请更换后重新提问。',
  256. 'attachment': {'kind': kind, 'index': index, 'title': title,
  257. 'error': 'invalid_attachment_url'}})
  258. return reject('invalid_request', 422)
  259. except (ValueError, UnicodeError, ClientDisconnect):
  260. return reject('invalid_request', 422)
  261. job, status, error = request.app.state.service.submit(thread_id, question, request_id, received_time,
  262. pictures=pictures, files=files, received_monotonic=received_at)
  263. if error:
  264. return reject(error, status, thread_id, retry=True)
  265. return StreamingResponse(request.app.state.service.stream(job), media_type='text/event-stream',
  266. headers={'Cache-Control': 'no-cache, no-transform', 'X-Accel-Buffering': 'no',
  267. 'X-Request-ID': request_id})
  268. async def health(request):
  269. return JSONResponse({'status': 'ready'})
  270. return Starlette(routes=[Route('/api/chat', chat, methods=['POST']),
  271. Route('/api/company/classify', classify_company, methods=['POST']),
  272. Route('/health', health)], lifespan=lifespan)