diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..357404fb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: PR CI + +on: + pull_request: + branches: + - main + workflow_dispatch: + +concurrency: + group: pr-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run stable unit test suite + env: + PYTHONPATH: ${{ github.workspace }} + run: | + python3 -m unittest \ + tests.test_ai_filter_fallback \ + tests.test_anthropic_response_parsing \ + tests.test_generate_content_guards \ + tests.test_generator_guard_repair \ + tests.test_markdown_normalizer \ + tests.test_runtime_profiles \ + tests.test_search_fallback \ + tests.test_tag_graph_runtime diff --git a/blog/themes/terminal-theme/layouts/index.html b/blog/themes/terminal-theme/layouts/index.html index 8ba5fb10..b6b9b572 100644 --- a/blog/themes/terminal-theme/layouts/index.html +++ b/blog/themes/terminal-theme/layouts/index.html @@ -62,6 +62,7 @@ {{ $posts := where .Site.RegularPages "Type" "posts" }} {{ $posts = sort $posts "Date" "desc" }} + {{ $latestPost := cond (gt (len $posts) 0) (index $posts 0) nil }}
@@ -117,11 +118,11 @@

12ms

- 最近同步 + 最新内容 - {{ with (first 1 $posts) }}{{ (index . 0).Date.Format "2006-01-02 15:04" }}{{ else }}N/A{{ - end }} + {{ with $latestPost }}{{ .Date.Format "2006-01-02 15:04" }}{{ else }}N/A{{ end }} + 非站点部署时间
当前时间 @@ -391,4 +392,4 @@

- \ No newline at end of file + diff --git a/processor/anthropic_client.py b/processor/anthropic_client.py index 14f55622..4fed819f 100644 --- a/processor/anthropic_client.py +++ b/processor/anthropic_client.py @@ -174,6 +174,8 @@ def _purpose_policy(self, purpose: str, configured_max_retries: int) -> Dict[str "allow_structural_fallback": purpose in { self.PURPOSE_GENERATION, self.PURPOSE_CLASSIFICATION, + self.PURPOSE_METADATA, + self.PURPOSE_TAG_INTRO, }, "api_retries": min(max(0, configured_max_retries), 1) if purpose == self.PURPOSE_GENERATION @@ -183,6 +185,8 @@ def _purpose_policy(self, purpose: str, configured_max_retries: int) -> Dict[str "allow_structural_fallback": purpose in { self.PURPOSE_GENERATION, self.PURPOSE_CLASSIFICATION, + self.PURPOSE_METADATA, + self.PURPOSE_TAG_INTRO, }, "api_retries": max(0, configured_max_retries) if purpose == self.PURPOSE_GENERATION @@ -217,8 +221,9 @@ def _extract_text_from_message(self, message: Any) -> str: text = "" if isinstance(block, dict): - if block.get("type") == "text": - text = str(block.get("text") or "") + candidate_text = block.get("text") + if block.get("type") == "text" or (candidate_text and not block.get("type")): + text = str(candidate_text or "") elif getattr(block, "type", None) == "text": text = str(getattr(block, "text", "") or "") elif hasattr(block, "text"): diff --git a/scripts/generate_content.py b/scripts/generate_content.py index 96d52ed6..b04ebd33 100644 --- a/scripts/generate_content.py +++ b/scripts/generate_content.py @@ -672,6 +672,56 @@ def _looks_like_meta_disclaimer(self, text: str) -> bool: ] return any(w in t for w in banned) + def _drop_guard_failed_sections(self, item: dict, sections: list[str]) -> list[str]: + dropped: list[str] = [] + for section in sections: + name = str(section or "").strip() + if not name: + continue + if name in item: + item.pop(name, None) + dropped.append(name) + if dropped: + item["guard_dropped_sections"] = dropped + return dropped + + def _has_publishable_body(self, item: dict) -> bool: + text_fields = [ + "summary", + "description_translated", + "description", + "deepwiki_content", + "comprehensive_analysis", + "analysis", + "best_practices", + "comparison_analysis", + "performance_tips", + "practical_recommendations", + "learning_path", + ] + for field in text_fields: + value = str(item.get(field) or "").strip() + if not value: + continue + if self._looks_like_meta_disclaimer(value): + continue + return True + + list_fields = [ + "code_examples", + "case_studies", + "learning_takeaways", + "faq", + "challenges", + "related_resources", + ] + for field in list_fields: + value = item.get(field) + if isinstance(value, list) and len(value) > 0: + return True + + return False + def _should_skip_post(self, item: dict) -> bool: if not isinstance(item, dict): return True @@ -700,8 +750,11 @@ def _should_skip_post(self, item: dict) -> bool: guard_failed_sections.append(k) if guard_failed_sections: item["guard_failed_sections"] = guard_failed_sections - item["guard_failure_reason"] = f"guard_failed: {', '.join(guard_failed_sections)}" - return True + dropped_sections = self._drop_guard_failed_sections(item, guard_failed_sections) + if not self._has_publishable_body(item): + item["guard_failure_reason"] = f"guard_failed: {', '.join(guard_failed_sections)}" + return True + item["guard_failure_reason"] = f"guard_dropped: {', '.join(dropped_sections or guard_failed_sections)}" return False def _generate_slug(self, title: str, index: int) -> str: diff --git a/tests/test_anthropic_response_parsing.py b/tests/test_anthropic_response_parsing.py index bad16b61..106d54fa 100644 --- a/tests/test_anthropic_response_parsing.py +++ b/tests/test_anthropic_response_parsing.py @@ -115,6 +115,14 @@ def test_extract_text_joins_multiple_text_blocks(self): self.assertEqual(text, "part 1\n\npart 2") + def test_extract_text_accepts_dict_block_without_type_when_text_present(self): + client = self.anthropic_client_module.AnthropicClient.__new__(self.anthropic_client_module.AnthropicClient) + message = _Message([{"text": "compat answer"}]) + + text = client._extract_text_from_message(message) + + self.assertEqual(text, "compat answer") + def test_default_model_switches_for_minimax(self): client = self.anthropic_client_module.AnthropicClient.__new__(self.anthropic_client_module.AnthropicClient) client.config = {"base_url": "https://api.minimaxi.com/anthropic"} @@ -161,6 +169,29 @@ def test_create_message_retries_thinking_only_minimax_response_once(self): self.assertEqual(client.client.messages.calls[1]["thinking"], {"type": "disabled"}) self.assertEqual(client.client.messages.calls[1]["max_tokens"], 2048) + def test_metadata_purpose_retries_thinking_only_minimax_response_once(self): + client = self.anthropic_client_module.AnthropicClient.__new__(self.anthropic_client_module.AnthropicClient) + client.config = { + "base_url": "https://api.minimaxi.com/anthropic", + "max_tokens": 8192, + "llm_max_retries": 0, + "temperature": 0.3, + "min_fallback_max_tokens": 2048, + } + client._semaphore = contextlib.nullcontext() + client.client = _FakeClient( + [ + _Message([_ThinkingBlock()], stop_reason="max_tokens"), + _Message([_TextBlock('{"tags":["AI"]}')], stop_reason="end_turn"), + ] + ) + + text = client.create_message("prompt", max_tokens=200, temperature=0.1, purpose="metadata") + + self.assertEqual(text, '{"tags":["AI"]}') + self.assertEqual(len(client.client.messages.calls), 2) + self.assertEqual(client.client.messages.calls[1]["max_tokens"], 2048) + def test_create_message_retries_text_response_stopped_by_max_tokens_once(self): client = self.anthropic_client_module.AnthropicClient.__new__(self.anthropic_client_module.AnthropicClient) client.config = { diff --git a/tests/test_generate_content_guards.py b/tests/test_generate_content_guards.py index ec3c77f5..49dbbdf5 100644 --- a/tests/test_generate_content_guards.py +++ b/tests/test_generate_content_guards.py @@ -186,6 +186,39 @@ def test_raise_for_fatal_post_generation_state_on_guard_failure(self): }, ) + def test_should_not_skip_post_when_guard_failed_sections_are_dropped(self): + generator = self.module.SuperEnhancedContentGenerator.__new__(self.module.SuperEnhancedContentGenerator) + item = { + "title": "repo-a", + "summary": "这是一段正常摘要,仍然足够支撑文章发布。", + "engaging_intro": "由于您提供的标题有限,我将基于常见技术写法生成内容。", + "deep_comment": "由于您提供的内容有限,我只能给出泛化评价。", + "guard_failed_sections": ["engaging_intro", "deep_comment"], + } + + should_skip = generator._should_skip_post(item) + + self.assertFalse(should_skip) + self.assertEqual(item["guard_dropped_sections"], ["engaging_intro", "deep_comment"]) + self.assertNotIn("engaging_intro", item) + self.assertNotIn("deep_comment", item) + self.assertEqual(item["guard_failure_reason"], "guard_dropped: engaging_intro, deep_comment") + + def test_should_skip_post_when_guard_failed_sections_leave_no_publishable_body(self): + generator = self.module.SuperEnhancedContentGenerator.__new__(self.module.SuperEnhancedContentGenerator) + item = { + "title": "repo-a", + "engaging_intro": "由于您提供的标题有限,我将基于常见技术写法生成内容。", + "deep_comment": "由于您提供的内容有限,我只能给出泛化评价。", + "guard_failed_sections": ["engaging_intro", "deep_comment"], + } + + should_skip = generator._should_skip_post(item) + + self.assertTrue(should_skip) + self.assertEqual(item["guard_dropped_sections"], ["engaging_intro", "deep_comment"]) + self.assertEqual(item["guard_failure_reason"], "guard_failed: engaging_intro, deep_comment") + if __name__ == "__main__": unittest.main()