diff --git a/src/uipath_explainator/gemini.py b/src/uipath_explainator/gemini.py index 6f968e6..4ec9614 100644 --- a/src/uipath_explainator/gemini.py +++ b/src/uipath_explainator/gemini.py @@ -14,6 +14,10 @@ UNKNOWN_TEXT = "无法从当前文件确定" logger = logging.getLogger(__name__) +class AnalysisError(RuntimeError): + """Raised when a single file analysis cannot be completed.""" + + @dataclass(slots=True) class GeminiAnalyzer: settings: Settings @@ -43,31 +47,37 @@ class GeminiAnalyzer: started = perf_counter() logger.info("Submitting Gemini analysis for %s (%d chars)", relative_path.as_posix(), len(content)) prompt = self._build_prompt(relative_path, content) - response = self._client.models.generate_content( - model=self.settings.model, - contents=prompt, - config=self._types.GenerateContentConfig( - temperature=0.2, - response_mime_type="application/json", - response_schema=self._response_schema(), - ), - ) - - response_text = response.text or "" - logger.debug( - "Gemini response received for %s (%d chars)", - relative_path.as_posix(), - len(response_text), - ) try: + response = self._client.models.generate_content( + model=self.settings.model, + contents=prompt, + config=self._types.GenerateContentConfig( + temperature=0.2, + response_mime_type="application/json", + response_schema=self._response_schema(), + ), + ) + + response_text = response.text or "" + logger.debug( + "Gemini response received for %s (%d chars)", + relative_path.as_posix(), + len(response_text), + ) payload = json.loads(response_text) - except json.JSONDecodeError: + except json.JSONDecodeError as exc: logger.exception( "Gemini returned invalid JSON for %s. Response snippet: %r", relative_path.as_posix(), response_text[:500], ) - raise + raise AnalysisError( + f"Gemini 返回了无法解析的 JSON,无法生成该文件说明。原始错误: {exc}" + ) from exc + except Exception as exc: + summary = self._summarize_error(exc) + logger.exception("Gemini analysis failed for %s: %s", relative_path.as_posix(), summary) + raise AnalysisError(summary) from exc logger.info( "Gemini analysis completed for %s in %.2fs", relative_path.as_posix(), @@ -75,6 +85,38 @@ class GeminiAnalyzer: ) return self._to_markdown(relative_path, payload) + def _summarize_error(self, exc: Exception) -> str: + status_code = getattr(exc, "status_code", None) + response_json = getattr(exc, "response_json", None) + upstream_message = self._extract_error_message(response_json) + + if status_code == 429: + detail = upstream_message or "Resource has been exhausted" + return ( + "Gemini 配额或速率限制已触发(HTTP 429),当前文件说明未生成。" + f"上游信息: {detail}。可稍后重试,或使用 --skip-analysis 仅导出代码与依赖。" + ) + + if status_code is not None: + detail = upstream_message or str(exc).strip() or exc.__class__.__name__ + return f"Gemini 调用失败(HTTP {status_code})。上游信息: {detail}" + + detail = str(exc).strip() + if detail: + return f"Gemini 分析失败: {detail}" + return f"Gemini 分析失败: {exc.__class__.__name__}" + + def _extract_error_message(self, response_json: Any) -> str | None: + if not isinstance(response_json, dict): + return None + + error = response_json.get("error") + if isinstance(error, dict): + message = error.get("message") + if isinstance(message, str) and message.strip(): + return message.strip() + return None + def _response_schema(self) -> dict[str, Any]: return { "type": "OBJECT", diff --git a/src/uipath_explainator/pipeline.py b/src/uipath_explainator/pipeline.py index 34a68bb..c8432f4 100644 --- a/src/uipath_explainator/pipeline.py +++ b/src/uipath_explainator/pipeline.py @@ -93,8 +93,8 @@ class ProjectPipeline: len(pruned_files), ) - analysis_files = self._write_analysis(final_rel_files, analyzer) - warnings = initial_scan.warnings + final_scan.warnings + analysis_files, analysis_warnings = self._write_analysis(final_rel_files, analyzer) + warnings = initial_scan.warnings + final_scan.warnings + analysis_warnings report = PipelineReport( project_root=self.project_root, @@ -177,21 +177,28 @@ class ProjectPipeline: directory.rmdir() logger.debug("Removed empty directory: %s", directory) - def _write_analysis(self, final_files: list[Path], analyzer) -> list[Path]: + def _write_analysis(self, final_files: list[Path], analyzer) -> tuple[list[Path], list[str]]: if analyzer is None: logger.info("Skipping Gemini analysis because analyzer is disabled") - return [] + return [], [] output_files: list[Path] = [] + warnings: list[str] = [] for relative_path in self._ordered_files(final_files): content = read_text(self.code_root / relative_path) - analysis = analyzer.analyze(relative_path, content) + try: + analysis = analyzer.analyze(relative_path, content) + except Exception as exc: + warning = f"Analysis failed for {relative_path.as_posix()}: {self._format_analysis_error(exc)}" + warnings.append(warning) + logger.warning(warning) + analysis = self._build_failed_analysis(relative_path, exc) analysis_path = self.docs_root / f"{relative_path.as_posix()}.analysis.md" analysis_path.parent.mkdir(parents=True, exist_ok=True) analysis_path.write_text(analysis, encoding="utf-8") output_files.append(Path(f"{relative_path.as_posix()}.analysis.md")) logger.debug("Wrote analysis file: %s", analysis_path) - return output_files + return output_files, warnings def _write_report_files(self, report: PipelineReport) -> None: (self.docs_root / "manifest.json").write_text(report.to_json(), encoding="utf-8") @@ -252,3 +259,23 @@ class ProjectPipeline: def _ordered_files(self, paths: list[Path]) -> list[Path]: return sorted(paths, key=lambda item: (item.suffix.lower() != ".xaml", item.as_posix().lower())) + + def _build_failed_analysis(self, relative_path: Path, exc: Exception) -> str: + reason = self._format_analysis_error(exc) + return "\n".join( + [ + f"# {relative_path.as_posix()}", + "", + "## 分析状态", + "- 状态:Gemini 分析失败,当前文件未生成结构化说明。", + f"- 原因:{reason}", + "- 建议:稍后重试;如果当前只需要导出代码与依赖,可使用 `--skip-analysis`。", + "", + ] + ) + + def _format_analysis_error(self, exc: Exception) -> str: + message = str(exc).strip() + if message: + return message + return exc.__class__.__name__ diff --git a/tests/test_gemini.py b/tests/test_gemini.py index 83ac7a4..b94441c 100644 --- a/tests/test_gemini.py +++ b/tests/test_gemini.py @@ -14,7 +14,7 @@ fake_dotenv.load_dotenv = lambda *args, **kwargs: None sys.modules.setdefault("dotenv", fake_dotenv) from uipath_explainator.config import Settings -from uipath_explainator.gemini import GeminiAnalyzer +from uipath_explainator.gemini import AnalysisError, GeminiAnalyzer class FakeHttpOptions: @@ -159,6 +159,33 @@ class GeminiAnalyzerTests(unittest.TestCase): self.assertIn("先讲这个文件在整个流程中的定位", prompt) self.assertIn("判断逻辑、调用链、输入输出、关键变量、外部依赖", prompt) + def test_analyze_wraps_rate_limit_error_with_clear_message(self) -> None: + fake_types = SimpleNamespace( + HttpOptions=FakeHttpOptions, + GenerateContentConfig=FakeGenerateContentConfig, + ) + fake_genai = ModuleType("google.genai") + fake_genai.Client = FakeClient + fake_genai.types = fake_types + + fake_google = ModuleType("google") + fake_google.genai = fake_genai + + with patch.dict(sys.modules, {"google": fake_google, "google.genai": fake_genai}): + analyzer = GeminiAnalyzer(Settings(api_key="test-key", base_url=None, model="gemini-test")) + + error = RuntimeError("quota exceeded") + error.status_code = 429 + error.response_json = {"error": {"message": "Resource has been exhausted (e.g. check quota)."}} + analyzer._client.models.generate_content = lambda **_: (_ for _ in ()).throw(error) + + with self.assertRaises(AnalysisError) as captured: + analyzer.analyze(Path("main.xaml"), "") + + self.assertIn("HTTP 429", str(captured.exception)) + self.assertIn("Resource has been exhausted", str(captured.exception)) + self.assertIn("--skip-analysis", str(captured.exception)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 25ee037..b773357 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -57,6 +57,13 @@ class StubAnalyzer: return f"# {relative_path.as_posix()}\n\n{len(content)}" +class FlakyAnalyzer: + def analyze(self, relative_path: Path, content: str) -> str: + if relative_path.name == "Active.xaml": + raise RuntimeError("HTTP 429 quota exhausted") + return f"# {relative_path.as_posix()}\n\n{len(content)}" + + class PipelineTests(unittest.TestCase): def test_strip_comment_out_blocks_removes_nested_blocks(self) -> None: source = "" @@ -145,6 +152,27 @@ class PipelineTests(unittest.TestCase): self.assertIn("Final scan complete:", combined) self.assertIn("Pipeline completed in", combined) + def test_pipeline_keeps_running_when_single_analysis_fails(self) -> None: + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + project_root = tmp_path / "project" + output_root = tmp_path / "workspace" + docs_root = output_root / "docs" + (project_root / "Flows").mkdir(parents=True) + (project_root / "Flows" / "Active.xaml").write_text(ACTIVE_XAML, encoding="utf-8") + (project_root / "Scripts").mkdir() + (project_root / "Scripts" / "Keep.bas").write_text("Sub Keep()\nEnd Sub", encoding="utf-8") + (project_root / "main.xaml").write_text(MAIN_XAML, encoding="utf-8") + + report = ProjectPipeline(project_root, output_root, "main.xaml", force=True).run(FlakyAnalyzer()) + + self.assertTrue((docs_root / "Flows" / "Active.xaml.analysis.md").exists()) + fallback = (docs_root / "Flows" / "Active.xaml.analysis.md").read_text(encoding="utf-8") + self.assertIn("Gemini 分析失败", fallback) + self.assertIn("HTTP 429 quota exhausted", fallback) + self.assertTrue((docs_root / "Scripts" / "Keep.bas.analysis.md").exists()) + self.assertTrue(any("Analysis failed for Flows/Active.xaml" in item for item in report.warnings)) + if __name__ == "__main__": unittest.main()