马一丁

Race condition in singleton ChartReviewService with concurrent renderers

@@ -294,16 +294,17 @@ class HTMLRenderer: @@ -294,16 +294,17 @@ class HTMLRenderer:
294 294
295 # 使用统一的 ChartReviewService 进行图表审查与修复 295 # 使用统一的 ChartReviewService 进行图表审查与修复
296 # 修复结果会直接回写到 document_ir,避免多次渲染重复修复 296 # 修复结果会直接回写到 document_ir,避免多次渲染重复修复
  297 + # review_document 返回本次会话的统计信息(线程安全)
297 chart_service = get_chart_review_service() 298 chart_service = get_chart_review_service()
298 - chart_service.review_document( 299 + review_stats = chart_service.review_document(
299 self.document, 300 self.document,
300 ir_file_path=ir_file_path, 301 ir_file_path=ir_file_path,
301 reset_stats=True, 302 reset_stats=True,
302 save_on_repair=bool(ir_file_path) 303 save_on_repair=bool(ir_file_path)
303 ) 304 )
304 # 同步统计信息到本地(用于兼容旧的 _log_chart_validation_stats) 305 # 同步统计信息到本地(用于兼容旧的 _log_chart_validation_stats)
305 - service_stats = chart_service.stats  
306 - self.chart_validation_stats.update(service_stats) 306 + # 使用返回的 ReviewStats 对象,而非共享的 chart_service.stats
  307 + self.chart_validation_stats.update(review_stats.to_dict())
307 308
308 self.widget_scripts = [] 309 self.widget_scripts = []
309 self.chart_counter = 0 310 self.chart_counter = 0
@@ -40,8 +40,9 @@ class MarkdownRenderer: @@ -40,8 +40,9 @@ class MarkdownRenderer:
40 40
41 # 使用统一的 ChartReviewService 进行图表审查与修复 41 # 使用统一的 ChartReviewService 进行图表审查与修复
42 # 虽然 Markdown 渲染时图表会降级为表格,但仍需确保数据有效 42 # 虽然 Markdown 渲染时图表会降级为表格,但仍需确保数据有效
  43 + # review_document 返回本次会话的统计信息(线程安全,此处不使用)
43 chart_service = get_chart_review_service() 44 chart_service = get_chart_review_service()
44 - chart_service.review_document( 45 + _ = chart_service.review_document(
45 self.document, 46 self.document,
46 ir_file_path=ir_file_path, 47 ir_file_path=ir_file_path,
47 reset_stats=True, 48 reset_stats=True,
@@ -173,22 +173,22 @@ class PDFRenderer: @@ -173,22 +173,22 @@ class PDFRenderer:
173 Dict[str, Any]: 修复后的Document IR(深拷贝) 173 Dict[str, Any]: 修复后的Document IR(深拷贝)
174 """ 174 """
175 # 使用统一的 ChartReviewService 175 # 使用统一的 ChartReviewService
  176 + # review_document 返回本次会话的统计信息(线程安全)
176 chart_service = get_chart_review_service() 177 chart_service = get_chart_review_service()
177 - chart_service.review_document( 178 + review_stats = chart_service.review_document(
178 document_ir, 179 document_ir,
179 ir_file_path=ir_file_path, 180 ir_file_path=ir_file_path,
180 reset_stats=True, 181 reset_stats=True,
181 save_on_repair=bool(ir_file_path) 182 save_on_repair=bool(ir_file_path)
182 ) 183 )
183 184
184 - stats = chart_service.stats  
185 - if stats.get('total', 0) > 0:  
186 - repaired_count = stats.get('repaired_locally', 0) + stats.get('repaired_api', 0) 185 + # 使用返回的 ReviewStats 对象,而非共享的 chart_service.stats
  186 + if review_stats.total > 0:
187 logger.info( 187 logger.info(
188 f"PDF图表预处理完成: " 188 f"PDF图表预处理完成: "
189 - f"总计 {stats.get('total', 0)} 个图表, "  
190 - f"修复 {repaired_count} 个, "  
191 - f"失败 {stats.get('failed', 0)} 个" 189 + f"总计 {review_stats.total} 个图表, "
  190 + f"修复 {review_stats.repaired_total} 个, "
  191 + f"失败 {review_stats.failed} 个"
192 ) 192 )
193 193
194 # 返回深拷贝,避免后续 SVG 转换过程影响回写后的原始 IR 194 # 返回深拷贝,避免后续 SVG 转换过程影响回写后的原始 IR
@@ -6,6 +6,7 @@ Report Engine工具模块。 @@ -6,6 +6,7 @@ Report Engine工具模块。
6 6
7 from ReportEngine.utils.chart_review_service import ( 7 from ReportEngine.utils.chart_review_service import (
8 ChartReviewService, 8 ChartReviewService,
  9 + ReviewStats,
9 get_chart_review_service, 10 get_chart_review_service,
10 review_document_charts, 11 review_document_charts,
11 ) 12 )
@@ -21,6 +22,7 @@ from ReportEngine.utils.table_validator import ( @@ -21,6 +22,7 @@ from ReportEngine.utils.table_validator import (
21 22
22 __all__ = [ 23 __all__ = [
23 "ChartReviewService", 24 "ChartReviewService",
  25 + "ReviewStats",
24 "get_chart_review_service", 26 "get_chart_review_service",
25 "review_document_charts", 27 "review_document_charts",
26 "TableValidator", 28 "TableValidator",
@@ -3,6 +3,11 @@ @@ -3,6 +3,11 @@
3 3
4 提供单例服务,确保所有渲染器共享修复状态,避免重复修复。 4 提供单例服务,确保所有渲染器共享修复状态,避免重复修复。
5 修复成功后可自动持久化到 IR 文件。 5 修复成功后可自动持久化到 IR 文件。
  6 +
  7 +线程安全说明:
  8 +- 验证器和修复器实例是无状态的,可安全共享
  9 +- 每次 review_document 调用会创建独立的 ReviewSession
  10 +- 统计信息通过 ReviewSession 返回,避免并发竞争
6 """ 11 """
7 12
8 from __future__ import annotations 13 from __future__ import annotations
@@ -10,6 +15,7 @@ from __future__ import annotations @@ -10,6 +15,7 @@ from __future__ import annotations
10 import copy 15 import copy
11 import json 16 import json
12 import threading 17 import threading
  18 +from dataclasses import dataclass
13 from pathlib import Path 19 from pathlib import Path
14 from typing import Any, Dict, List, Optional 20 from typing import Any, Dict, List, Optional
15 21
@@ -25,6 +31,36 @@ from ReportEngine.utils.chart_validator import ( @@ -25,6 +31,36 @@ from ReportEngine.utils.chart_validator import (
25 from ReportEngine.utils.chart_repair_api import create_llm_repair_functions 31 from ReportEngine.utils.chart_repair_api import create_llm_repair_functions
26 32
27 33
  34 +@dataclass
  35 +class ReviewStats:
  36 + """
  37 + 图表审查统计信息 - 每次审查会话独立的统计数据。
  38 +
  39 + 通过为每次 review_document 调用创建独立的 ReviewStats 实例,
  40 + 避免多线程并发时的统计数据竞争问题。
  41 + """
  42 + total: int = 0
  43 + valid: int = 0
  44 + repaired_locally: int = 0
  45 + repaired_api: int = 0
  46 + failed: int = 0
  47 +
  48 + def to_dict(self) -> Dict[str, int]:
  49 + """转换为字典格式"""
  50 + return {
  51 + 'total': self.total,
  52 + 'valid': self.valid,
  53 + 'repaired_locally': self.repaired_locally,
  54 + 'repaired_api': self.repaired_api,
  55 + 'failed': self.failed
  56 + }
  57 +
  58 + @property
  59 + def repaired_total(self) -> int:
  60 + """修复总数"""
  61 + return self.repaired_locally + self.repaired_api
  62 +
  63 +
28 class ChartReviewService: 64 class ChartReviewService:
29 """ 65 """
30 图表审查服务 - 单例模式。 66 图表审查服务 - 单例模式。
@@ -33,7 +69,12 @@ class ChartReviewService: @@ -33,7 +69,12 @@ class ChartReviewService:
33 1. 统一管理图表验证和修复 69 1. 统一管理图表验证和修复
34 2. 维护修复缓存,避免重复修复 70 2. 维护修复缓存,避免重复修复
35 3. 支持修复后自动持久化到 IR 文件 71 3. 支持修复后自动持久化到 IR 文件
36 - 4. 提供统计信息 72 + 4. 提供统计信息(通过 ReviewStats 返回,线程安全)
  73 +
  74 + 线程安全说明:
  75 + - validator 和 repairer 是无状态的,可安全共享
  76 + - 每次 review_document 调用创建独立的 ReviewStats
  77 + - 不再使用全局 _stats,避免并发竞争
37 """ 78 """
38 79
39 _instance: Optional["ChartReviewService"] = None 80 _instance: Optional["ChartReviewService"] = None
@@ -55,7 +96,7 @@ class ChartReviewService: @@ -55,7 +96,7 @@ class ChartReviewService:
55 96
56 self._initialized = True 97 self._initialized = True
57 98
58 - # 初始化验证器和修复器 99 + # 初始化验证器和修复器(无状态,可安全共享)
59 self.validator = create_chart_validator() 100 self.validator = create_chart_validator()
60 self.llm_repair_fns = create_llm_repair_functions() 101 self.llm_repair_fns = create_llm_repair_functions()
61 self.repairer = create_chart_repairer( 102 self.repairer = create_chart_repairer(
@@ -69,31 +110,44 @@ class ChartReviewService: @@ -69,31 +110,44 @@ class ChartReviewService:
69 else: 110 else:
70 logger.info(f"ChartReviewService: 已配置 {len(self.llm_repair_fns)} 个 LLM 修复函数") 111 logger.info(f"ChartReviewService: 已配置 {len(self.llm_repair_fns)} 个 LLM 修复函数")
71 112
72 - # 统计信息  
73 - self._stats = {  
74 - 'total': 0,  
75 - 'valid': 0,  
76 - 'repaired_locally': 0,  
77 - 'repaired_api': 0,  
78 - 'failed': 0  
79 - } 113 + # 最后一次审查的统计信息(仅用于向后兼容,不推荐在并发场景使用)
  114 + # 新代码应使用 review_document 返回的 ReviewStats
  115 + self._last_stats: Optional[ReviewStats] = None
  116 + self._last_stats_lock = threading.Lock()
80 117
81 logger.info("ChartReviewService 初始化完成") 118 logger.info("ChartReviewService 初始化完成")
82 119
83 def reset_stats(self) -> None: 120 def reset_stats(self) -> None:
84 - """重置统计信息"""  
85 - self._stats = {  
86 - 'total': 0,  
87 - 'valid': 0,  
88 - 'repaired_locally': 0,  
89 - 'repaired_api': 0,  
90 - 'failed': 0  
91 - } 121 + """
  122 + 重置统计信息(向后兼容,不推荐使用)。
  123 +
  124 + 注意:此方法仅用于向后兼容。在并发场景下,
  125 + 应使用 review_document 返回的 ReviewStats 对象。
  126 + """
  127 + with self._last_stats_lock:
  128 + self._last_stats = None
92 129
93 @property 130 @property
94 def stats(self) -> Dict[str, int]: 131 def stats(self) -> Dict[str, int]:
95 - """获取统计信息副本"""  
96 - return self._stats.copy() 132 + """
  133 + 获取最后一次审查的统计信息副本(向后兼容)。
  134 +
  135 + 警告:在并发场景下,此属性可能返回其他线程的统计结果。
  136 + 推荐使用 review_document 返回的 ReviewStats 对象。
  137 +
  138 + 返回:
  139 + Dict[str, int]: 统计信息字典副本
  140 + """
  141 + with self._last_stats_lock:
  142 + if self._last_stats is None:
  143 + return {
  144 + 'total': 0,
  145 + 'valid': 0,
  146 + 'repaired_locally': 0,
  147 + 'repaired_api': 0,
  148 + 'failed': 0
  149 + }
  150 + return self._last_stats.to_dict()
97 151
98 def review_document( 152 def review_document(
99 self, 153 self,
@@ -102,28 +156,33 @@ class ChartReviewService: @@ -102,28 +156,33 @@ class ChartReviewService:
102 *, 156 *,
103 reset_stats: bool = True, 157 reset_stats: bool = True,
104 save_on_repair: bool = True 158 save_on_repair: bool = True
105 - ) -> Dict[str, Any]: 159 + ) -> ReviewStats:
106 """ 160 """
107 审查并修复文档中的所有图表。 161 审查并修复文档中的所有图表。
108 162
109 遍历所有章节的 blocks,检测图表类型的 widget, 163 遍历所有章节的 blocks,检测图表类型的 widget,
110 对未审查过的图表进行验证和修复。 164 对未审查过的图表进行验证和修复。
111 165
  166 + 线程安全:每次调用创建独立的 ReviewStats,避免并发竞争。
  167 +
112 参数: 168 参数:
113 document_ir: Document IR 数据 169 document_ir: Document IR 数据
114 ir_file_path: IR 文件路径,如果提供且有修复,会自动保存 170 ir_file_path: IR 文件路径,如果提供且有修复,会自动保存
115 - reset_stats: 是否重置统计信息 171 + reset_stats: 保留参数以保持向后兼容,不再有实际作用
116 save_on_repair: 修复后是否自动保存到文件 172 save_on_repair: 修复后是否自动保存到文件
117 173
118 返回: 174 返回:
119 - Dict[str, Any]: 审查后的 Document IR(原对象,已修改 175 + ReviewStats: 本次审查的统计信息(线程安全
120 """ 176 """
121 - if reset_stats:  
122 - self.reset_stats() 177 + # 每次调用创建独立的统计对象,避免并发竞争
  178 + session_stats = ReviewStats()
123 179
124 if not document_ir: 180 if not document_ir:
125 logger.warning("ChartReviewService: document_ir 为空,跳过审查") 181 logger.warning("ChartReviewService: document_ir 为空,跳过审查")
126 - return document_ir 182 + # 更新 _last_stats 以保持向后兼容
  183 + with self._last_stats_lock:
  184 + self._last_stats = session_stats
  185 + return session_stats
127 186
128 has_repairs = False 187 has_repairs = False
129 188
@@ -133,27 +192,37 @@ class ChartReviewService: @@ -133,27 +192,37 @@ class ChartReviewService:
133 continue 192 continue
134 blocks = chapter.get("blocks", []) 193 blocks = chapter.get("blocks", [])
135 if isinstance(blocks, list): 194 if isinstance(blocks, list):
136 - chapter_repairs = self._walk_and_review_blocks(blocks, chapter) 195 + chapter_repairs = self._walk_and_review_blocks(blocks, chapter, session_stats)
137 if chapter_repairs: 196 if chapter_repairs:
138 has_repairs = True 197 has_repairs = True
139 198
140 # 输出统计信息 199 # 输出统计信息
141 - self._log_stats() 200 + self._log_stats(session_stats)
  201 +
  202 + # 更新 _last_stats 以保持向后兼容
  203 + with self._last_stats_lock:
  204 + self._last_stats = session_stats
142 205
143 # 如果有修复且提供了文件路径,保存到文件 206 # 如果有修复且提供了文件路径,保存到文件
144 if has_repairs and ir_file_path and save_on_repair: 207 if has_repairs and ir_file_path and save_on_repair:
145 self._save_ir_to_file(document_ir, ir_file_path) 208 self._save_ir_to_file(document_ir, ir_file_path)
146 209
147 - return document_ir 210 + return session_stats
148 211
149 def _walk_and_review_blocks( 212 def _walk_and_review_blocks(
150 self, 213 self,
151 blocks: List[Any], 214 blocks: List[Any],
152 - chapter_context: Dict[str, Any] | None = None 215 + chapter_context: Dict[str, Any] | None,
  216 + session_stats: ReviewStats
153 ) -> bool: 217 ) -> bool:
154 """ 218 """
155 递归遍历 blocks 并审查图表。 219 递归遍历 blocks 并审查图表。
156 220
  221 + 参数:
  222 + blocks: 要遍历的 block 列表
  223 + chapter_context: 章节上下文
  224 + session_stats: 本次审查会话的统计对象
  225 +
157 返回: 226 返回:
158 bool: 是否有修复发生 227 bool: 是否有修复发生
159 """ 228 """
@@ -165,21 +234,21 @@ class ChartReviewService: @@ -165,21 +234,21 @@ class ChartReviewService:
165 234
166 # 检查是否是图表 widget 235 # 检查是否是图表 widget
167 if block.get("type") == "widget": 236 if block.get("type") == "widget":
168 - repaired = self._review_chart_block(block, chapter_context) 237 + repaired = self._review_chart_block(block, chapter_context, session_stats)
169 if repaired: 238 if repaired:
170 has_repairs = True 239 has_repairs = True
171 240
172 # 递归处理嵌套的 blocks 241 # 递归处理嵌套的 blocks
173 nested_blocks = block.get("blocks") 242 nested_blocks = block.get("blocks")
174 if isinstance(nested_blocks, list): 243 if isinstance(nested_blocks, list):
175 - if self._walk_and_review_blocks(nested_blocks, chapter_context): 244 + if self._walk_and_review_blocks(nested_blocks, chapter_context, session_stats):
176 has_repairs = True 245 has_repairs = True
177 246
178 # 处理 list 类型的 items 247 # 处理 list 类型的 items
179 if block.get("type") == "list": 248 if block.get("type") == "list":
180 for item in block.get("items", []): 249 for item in block.get("items", []):
181 if isinstance(item, list): 250 if isinstance(item, list):
182 - if self._walk_and_review_blocks(item, chapter_context): 251 + if self._walk_and_review_blocks(item, chapter_context, session_stats):
183 has_repairs = True 252 has_repairs = True
184 253
185 # 处理 table 类型的 cells 254 # 处理 table 类型的 cells
@@ -191,7 +260,7 @@ class ChartReviewService: @@ -191,7 +260,7 @@ class ChartReviewService:
191 if isinstance(cell, dict): 260 if isinstance(cell, dict):
192 cell_blocks = cell.get("blocks", []) 261 cell_blocks = cell.get("blocks", [])
193 if isinstance(cell_blocks, list): 262 if isinstance(cell_blocks, list):
194 - if self._walk_and_review_blocks(cell_blocks, chapter_context): 263 + if self._walk_and_review_blocks(cell_blocks, chapter_context, session_stats):
195 has_repairs = True 264 has_repairs = True
196 265
197 return has_repairs 266 return has_repairs
@@ -199,11 +268,17 @@ class ChartReviewService: @@ -199,11 +268,17 @@ class ChartReviewService:
199 def _review_chart_block( 268 def _review_chart_block(
200 self, 269 self,
201 block: Dict[str, Any], 270 block: Dict[str, Any],
202 - chapter_context: Dict[str, Any] | None = None 271 + chapter_context: Dict[str, Any] | None,
  272 + session_stats: ReviewStats
203 ) -> bool: 273 ) -> bool:
204 """ 274 """
205 审查单个图表 block。 275 审查单个图表 block。
206 276
  277 + 参数:
  278 + block: 要审查的 block
  279 + chapter_context: 章节上下文
  280 + session_stats: 本次审查会话的统计对象
  281 +
207 返回: 282 返回:
208 bool: 是否进行了修复 283 bool: 是否进行了修复
209 """ 284 """
@@ -225,11 +300,11 @@ class ChartReviewService: @@ -225,11 +300,11 @@ class ChartReviewService:
225 logger.debug(f"图表 {widget_id} 已审查过,跳过") 300 logger.debug(f"图表 {widget_id} 已审查过,跳过")
226 return False 301 return False
227 302
228 - self._stats['total'] += 1 303 + session_stats.total += 1
229 304
230 # 词云直接标记为有效 305 # 词云直接标记为有效
231 if is_wordcloud: 306 if is_wordcloud:
232 - self._stats['valid'] += 1 307 + session_stats.valid += 1
233 block["_chart_reviewed"] = True 308 block["_chart_reviewed"] = True
234 block["_chart_review_status"] = "valid" 309 block["_chart_review_status"] = "valid"
235 block["_chart_review_method"] = "none" 310 block["_chart_review_method"] = "none"
@@ -243,7 +318,7 @@ class ChartReviewService: @@ -243,7 +318,7 @@ class ChartReviewService:
243 318
244 if validation_result.is_valid: 319 if validation_result.is_valid:
245 # 验证通过 320 # 验证通过
246 - self._stats['valid'] += 1 321 + session_stats.valid += 1
247 block["_chart_reviewed"] = True 322 block["_chart_reviewed"] = True
248 block["_chart_review_status"] = "valid" 323 block["_chart_review_status"] = "valid"
249 block["_chart_review_method"] = "none" 324 block["_chart_review_method"] = "none"
@@ -269,9 +344,9 @@ class ChartReviewService: @@ -269,9 +344,9 @@ class ChartReviewService:
269 344
270 method = repair_result.method or "local" 345 method = repair_result.method or "local"
271 if method == "local": 346 if method == "local":
272 - self._stats['repaired_locally'] += 1 347 + session_stats.repaired_locally += 1
273 elif method == "api": 348 elif method == "api":
274 - self._stats['repaired_api'] += 1 349 + session_stats.repaired_api += 1
275 350
276 block["_chart_reviewed"] = True 351 block["_chart_reviewed"] = True
277 block["_chart_review_status"] = "repaired" 352 block["_chart_review_status"] = "repaired"
@@ -281,7 +356,7 @@ class ChartReviewService: @@ -281,7 +356,7 @@ class ChartReviewService:
281 return True 356 return True
282 357
283 # 修复失败 358 # 修复失败
284 - self._stats['failed'] += 1 359 + session_stats.failed += 1
285 block["_chart_reviewed"] = True 360 block["_chart_reviewed"] = True
286 block["_chart_renderable"] = False 361 block["_chart_renderable"] = False
287 block["_chart_review_status"] = "failed" 362 block["_chart_review_status"] = "failed"
@@ -412,19 +487,18 @@ class ChartReviewService: @@ -412,19 +487,18 @@ class ChartReviewService:
412 return "验证失败但无具体错误信息" 487 return "验证失败但无具体错误信息"
413 return "; ".join(errors[:3]) 488 return "; ".join(errors[:3])
414 489
415 - def _log_stats(self) -> None: 490 + def _log_stats(self, stats: ReviewStats) -> None:
416 """输出统计信息""" 491 """输出统计信息"""
417 - if self._stats['total'] == 0: 492 + if stats.total == 0:
418 logger.debug("ChartReviewService: 没有图表需要审查") 493 logger.debug("ChartReviewService: 没有图表需要审查")
419 return 494 return
420 495
421 - repaired = self._stats['repaired_locally'] + self._stats['repaired_api']  
422 logger.info( 496 logger.info(
423 f"ChartReviewService 图表审查完成: " 497 f"ChartReviewService 图表审查完成: "
424 - f"总计 {self._stats['total']} 个, "  
425 - f"有效 {self._stats['valid']} 个, "  
426 - f"修复 {repaired} 个 (本地 {self._stats['repaired_locally']}, API {self._stats['repaired_api']}), "  
427 - f"失败 {self._stats['failed']} 个" 498 + f"总计 {stats.total} 个, "
  499 + f"有效 {stats.valid} 个, "
  500 + f"修复 {stats.repaired_total} 个 (本地 {stats.repaired_locally}, API {stats.repaired_api}), "
  501 + f"失败 {stats.failed} 个"
428 ) 502 )
429 503
430 # 内部元数据键,不应保存到 IR 文件 504 # 内部元数据键,不应保存到 IR 文件
@@ -526,18 +600,18 @@ def review_document_charts( @@ -526,18 +600,18 @@ def review_document_charts(
526 *, 600 *,
527 reset_stats: bool = True, 601 reset_stats: bool = True,
528 save_on_repair: bool = True 602 save_on_repair: bool = True
529 -) -> Dict[str, Any]: 603 +) -> ReviewStats:
530 """ 604 """
531 便捷函数:审查并修复文档中的所有图表。 605 便捷函数:审查并修复文档中的所有图表。
532 606
533 参数: 607 参数:
534 document_ir: Document IR 数据 608 document_ir: Document IR 数据
535 ir_file_path: IR 文件路径,如果提供且有修复,会自动保存 609 ir_file_path: IR 文件路径,如果提供且有修复,会自动保存
536 - reset_stats: 是否重置统计信息 610 + reset_stats: 保留参数以保持向后兼容,不再有实际作用
537 save_on_repair: 修复后是否自动保存到文件 611 save_on_repair: 修复后是否自动保存到文件
538 612
539 返回: 613 返回:
540 - Dict[str, Any]: 审查后的 Document IR 614 + ReviewStats: 本次审查的统计信息
541 """ 615 """
542 service = get_chart_review_service() 616 service = get_chart_review_service()
543 return service.review_document( 617 return service.review_document(
@@ -550,6 +624,7 @@ def review_document_charts( @@ -550,6 +624,7 @@ def review_document_charts(
550 624
551 __all__ = [ 625 __all__ = [
552 "ChartReviewService", 626 "ChartReviewService",
  627 + "ReviewStats",
553 "get_chart_review_service", 628 "get_chart_review_service",
554 "review_document_charts", 629 "review_document_charts",
555 ] 630 ]