streaming.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. """Request-scoped public text streaming; internal model fields never leave here."""
  2. from contextlib import contextmanager
  3. from contextvars import ContextVar
  4. import json
  5. from http.client import HTTPException
  6. import time
  7. import urllib.error
  8. import urllib.request
  9. from uuid import uuid4
  10. from src.step1_knowledge_prepare.summarize import RequestFailure, deepseek_response
  11. from .timing import mark
  12. _session = ContextVar('public_reply_stream', default=None)
  13. _field = ContextVar('public_reply_field', default=None)
  14. class ReplyStream:
  15. def __init__(self, emit, stopped):
  16. self.emit, self.stopped = emit, stopped
  17. self.active = {}
  18. self.prefixes = {}
  19. self.texts = {}
  20. self.published = {'source': set(), 'item': set()}
  21. def recommendation(self, sources, items):
  22. """Publish finalized evidence/cards once, before public prose starts."""
  23. for event, rows, key in (('source', sources, 'id'), ('item', items, 'source_id')):
  24. for row in rows:
  25. self.check()
  26. identity = row[key]
  27. if identity not in self.published[event]:
  28. self.emit(event, row)
  29. self.published[event].add(identity)
  30. def check(self):
  31. if self.stopped.is_set():
  32. raise RequestFailure('stream_cancelled', False)
  33. def start(self, target):
  34. self.check()
  35. self.abort(target, 'regenerating')
  36. item = {'stream_id': uuid4().hex, 'target': target, 'sequence': 0}
  37. self.active[target] = item
  38. self.texts[target] = ''
  39. self.emit('answer_start', {**item, 'provisional': True})
  40. if self.prefixes.get(target):
  41. self.delta(target, item['stream_id'], self.prefixes[target])
  42. return item['stream_id']
  43. def delta(self, target, identity, text):
  44. self.check()
  45. item = self.active.get(target)
  46. if not item or item['stream_id'] != identity:
  47. raise RequestFailure('stream_cancelled', False)
  48. item['sequence'] += 1
  49. self.texts[target] += text
  50. self.emit('answer_delta', {**item, 'text': text})
  51. def complete(self, target, text, accepted=True):
  52. item = self.active.get(target)
  53. if item is None:
  54. return {}
  55. draft = self.texts[target]
  56. # Program additions are known only after validation. Append them once;
  57. # replacements (fallbacks, bound links, edits) remain authoritative end.
  58. if accepted and text.startswith(draft) and len(text) > len(draft):
  59. self.delta(target, item['stream_id'], text[len(draft):])
  60. self.active.pop(target)
  61. self.texts.pop(target, None)
  62. self.emit('answer_end', {**item, 'text': text, 'operation': 'replace',
  63. 'status': 'completed' if accepted else 'fallback', 'provisional': False})
  64. return {'stream_id': item['stream_id']}
  65. def abort(self, target, reason='generation_failed'):
  66. item = self.active.pop(target, None)
  67. self.texts.pop(target, None)
  68. if item:
  69. self.emit('answer_abort', {**item, 'reason': reason, 'operation': 'discard'})
  70. @contextmanager
  71. def reply_stream(emit, stopped):
  72. session = ReplyStream(emit, stopped)
  73. token = _session.set(session)
  74. try:
  75. yield session
  76. finally:
  77. for target in list(session.active):
  78. session.abort(target, 'not_finalized')
  79. _session.reset(token)
  80. def current_stream():
  81. return _session.get()
  82. @contextmanager
  83. def public_text(target='summary', field='text', gate=None):
  84. token = _field.set((target, field, gate))
  85. try:
  86. yield
  87. except Exception:
  88. if _session.get() is not None:
  89. _session.get().abort(target)
  90. raise
  91. finally:
  92. _field.reset(token)
  93. class JsonTextField:
  94. """Incrementally decode one top-level string, including split surrogate pairs.
  95. This is a display extractor, not a JSON validator. Full JSON and references
  96. are still validated by the caller before the final replacement is emitted.
  97. """
  98. def __init__(self, field, emit, gate=None):
  99. self.field, self.emit, self.gate = field, emit, gate
  100. self.depth = 0
  101. self.expect_key = False
  102. self.key = None
  103. self.string = None
  104. self.raw = ''
  105. self.escape = False
  106. self.pending = ''
  107. self.fields = {}
  108. self.finished = False
  109. self.seen_target = False
  110. def _decode(self, final=False):
  111. result, offset = [], 0
  112. while offset < len(self.pending):
  113. char = self.pending[offset]
  114. width = 1
  115. if char == '\\':
  116. if offset + 1 >= len(self.pending):
  117. break
  118. width = 6 if self.pending[offset + 1] == 'u' else 2
  119. if offset + width > len(self.pending):
  120. break
  121. token = self.pending[offset:offset + width]
  122. if width == 6:
  123. try:
  124. number = int(token[2:], 16)
  125. except ValueError:
  126. raise RequestFailure('invalid_response_json', False) from None
  127. if 0xD800 <= number <= 0xDBFF:
  128. if offset + 12 > len(self.pending):
  129. break
  130. width = 12
  131. token = self.pending[offset:offset + width]
  132. try:
  133. char = json.loads('"' + token + '"')
  134. except (ValueError, UnicodeError):
  135. raise RequestFailure('invalid_response_json', False) from None
  136. if any(0xD800 <= ord(c) <= 0xDFFF for c in char):
  137. raise RequestFailure('invalid_response_json', False)
  138. result.append(char)
  139. offset += width
  140. self.pending = self.pending[offset:]
  141. if final and self.pending:
  142. raise RequestFailure('invalid_response_json', False)
  143. text = ''.join(result)
  144. if text and (self.gate is None or self.gate(self.fields)):
  145. self.emit(text)
  146. def feed(self, chunk):
  147. if self.finished:
  148. return
  149. for char in chunk:
  150. if self.string is not None:
  151. if char == '"' and not self.escape:
  152. kind = self.string
  153. self.string = None
  154. if kind == 'key':
  155. self.key = json.loads('"' + self.raw + '"')
  156. self.expect_key = False
  157. elif kind == 'target':
  158. self._decode(final=True)
  159. self.finished = True
  160. break
  161. elif kind == 'value':
  162. self.fields[self.key] = json.loads('"' + self.raw + '"')
  163. self.raw = ''
  164. continue
  165. if self.string == 'target':
  166. self.pending += char
  167. elif self.string in ('key', 'value'):
  168. self.raw += char
  169. if self.escape:
  170. self.escape = False
  171. elif char == '\\':
  172. self.escape = True
  173. continue
  174. if char == '"':
  175. if self.depth == 1 and self.expect_key:
  176. self.string = 'key'
  177. elif self.depth == 1 and self.key == self.field:
  178. if self.seen_target:
  179. raise RequestFailure('invalid_response_json', False)
  180. self.seen_target = True
  181. self.string = 'target'
  182. else:
  183. self.string = 'value' if self.depth == 1 else 'other'
  184. self.raw = ''
  185. self.escape = False
  186. elif char in '{[':
  187. self.depth += 1
  188. if self.depth == 1:
  189. self.expect_key = True
  190. elif char in '}]':
  191. self.depth -= 1
  192. elif char == ',' and self.depth == 1:
  193. self.expect_key = True
  194. self.key = None
  195. if self.string == 'target':
  196. self._decode()
  197. def stream_transport(payload, config, on_content, check=lambda: None):
  198. """Read actual provider SSE deltas and reconstruct its normal response shape."""
  199. total, event, parts, finish, done = 0, [], [], None, False
  200. started = time.monotonic()
  201. try:
  202. check()
  203. with deepseek_response({**payload, 'stream': True}, config, stream=True, check=check) as response:
  204. for line in response_lines(response, check, started + config.timeout):
  205. check()
  206. if time.monotonic() - started > config.timeout:
  207. raise RequestFailure('request_timeout')
  208. total += len(line)
  209. if total > 4_000_000 or len(line) > 1_000_000:
  210. raise RequestFailure('response_too_large', False)
  211. if not line:
  212. break
  213. line = line.decode('utf-8').rstrip('\r\n')
  214. if line.startswith('data:'):
  215. event.append(line[5:].lstrip(' '))
  216. elif not line and event:
  217. data, event = '\n'.join(event), []
  218. if data == '[DONE]':
  219. if done:
  220. raise RequestFailure('invalid_response_json', False)
  221. done = True
  222. continue
  223. if done:
  224. raise RequestFailure('invalid_response_json', False)
  225. value = json.loads(data)
  226. if not isinstance(value, dict) or 'error' in value or not isinstance(value.get('choices'), list):
  227. raise RequestFailure('invalid_response_json', False)
  228. if not value['choices']: # optional usage-only frame
  229. continue
  230. if len(value['choices']) != 1:
  231. raise RequestFailure('invalid_response_json', False)
  232. choice = value['choices'][0]
  233. if not isinstance(choice, dict) or choice.get('index', 0) != 0:
  234. raise RequestFailure('invalid_response_json', False)
  235. delta = choice.get('delta', {})
  236. if not isinstance(delta, dict):
  237. raise RequestFailure('invalid_response_json', False)
  238. text = delta.get('content')
  239. if text is not None and not isinstance(text, str):
  240. raise RequestFailure('invalid_response_json', False)
  241. if text:
  242. if finish is not None:
  243. raise RequestFailure('invalid_response_json', False)
  244. parts.append(text)
  245. on_content(text)
  246. reason = choice.get('finish_reason')
  247. if reason is not None:
  248. if not isinstance(reason, str) or finish is not None:
  249. raise RequestFailure('invalid_response_json', False)
  250. finish = reason
  251. if not done or finish is None or event:
  252. raise RequestFailure('stream_incomplete', False)
  253. return {'choices': [{'finish_reason': finish, 'message': {'content': ''.join(parts)}}]}
  254. except urllib.error.HTTPError as exc:
  255. code = exc.code
  256. exc.close()
  257. raise RequestFailure(f'http_{code}', False) from None
  258. except TimeoutError:
  259. raise RequestFailure('request_timeout') from None
  260. except urllib.error.URLError as exc:
  261. raise RequestFailure('request_timeout' if isinstance(exc.reason, TimeoutError) else 'network_failure') from None
  262. except OSError:
  263. raise RequestFailure('network_failure') from None
  264. except HTTPException:
  265. raise RequestFailure('response_incomplete', False) from None
  266. except (ValueError, UnicodeError):
  267. raise RequestFailure('invalid_response_json', False) from None
  268. def response_lines(response, check=lambda: None, deadline=None):
  269. """Incremental UTF-8-safe line framing; read1 also verifies HTTP chunks."""
  270. pending = b''
  271. total = 0
  272. while True:
  273. check()
  274. if deadline is not None and time.monotonic() > deadline:
  275. raise RequestFailure('request_timeout')
  276. chunk = response.read1(4096)
  277. if not chunk:
  278. if getattr(response, 'length', None) not in (None, 0):
  279. raise RequestFailure('response_incomplete', False)
  280. if pending:
  281. raise RequestFailure('stream_incomplete', False)
  282. return
  283. total += len(chunk)
  284. if total > 4_000_000:
  285. raise RequestFailure('response_too_large', False)
  286. pending += chunk
  287. while b'\n' in pending:
  288. line, pending = pending.split(b'\n', 1)
  289. if len(line) > 1_000_000:
  290. raise RequestFailure('response_too_large', False)
  291. yield line + b'\n'
  292. if len(pending) > 1_000_000:
  293. raise RequestFailure('response_too_large', False)
  294. def model_response(payload, config, fallback):
  295. session, spec = _session.get(), _field.get()
  296. if session is None or spec is None:
  297. return fallback(payload, config)
  298. target, field, gate = spec
  299. identity = None
  300. pending = ''
  301. def emit(text):
  302. nonlocal identity, pending
  303. # Finance URLs are bound to validated source IDs only after generation.
  304. # Hold split internal markers and omit them from provisional text.
  305. markers = {'[[finance_detail:': ']]', '[[service_detail:': ']]',
  306. '[[knowledge_image:': ']]', '<image-scope>': '</image-scope>'}
  307. pending += text
  308. visible = ''
  309. while pending:
  310. opening = next((marker for marker in markers if pending.startswith(marker)), None)
  311. if opening is not None:
  312. closing = markers[opening]
  313. end = pending.find(closing)
  314. if end < 0:
  315. break
  316. pending = pending[end + len(closing):]
  317. elif any(marker.startswith(pending) for marker in markers):
  318. break
  319. else:
  320. visible += pending[0]
  321. pending = pending[1:]
  322. if not visible:
  323. return
  324. if identity is None:
  325. identity = session.start(target)
  326. session.delta(target, identity, visible)
  327. mark('first_model_text')
  328. extractor = JsonTextField(field, emit, gate)
  329. try:
  330. return stream_transport(payload, config, extractor.feed, session.check)
  331. except Exception:
  332. if identity is not None:
  333. session.abort(target)
  334. raise