From cf6af6345e1a4f7c7cc4fe6c0cb865a797309bd6 Mon Sep 17 00:00:00 2001 From: fxbin Date: Sun, 18 Oct 2026 23:46:18 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(scoring):=20=E5=8F=A3=E5=BE=84=E5=B8=B8?= =?UTF-8?q?=E9=87=8F=E4=B8=8B=E6=B2=89=20core=20=E5=B9=B6=E5=B0=81?= =?UTF-8?q?=E6=AD=BB=20repositories=20=E9=80=86=E5=90=91=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 21 +-- Makefile | 2 +- backend/app/api/v1/contents.py | 6 +- .../app/{services => core}/scoring_caliber.py | 0 .../{services => core}/tag_normalization.py | 0 backend/app/repositories/content_repo.py | 6 +- backend/app/services/analysis.py | 2 +- backend/app/services/analysis_normalize.py | 2 +- backend/app/services/content_pipeline.py | 2 +- backend/app/services/llm/prompts/analysis.py | 4 +- backend/app/services/scoring_engine.py | 4 +- backend/scripts/check_layering.py | 79 +++++++++-- backend/scripts/scoring_anchor_ab.py | 2 +- backend/tests/test_check_layering.py | 123 ++++++++++++++++++ backend/tests/test_scoring_anchor_ab.py | 2 +- backend/tests/test_scoring_caliber.py | 6 +- backend/tests/test_tag_normalization.py | 4 +- 17 files changed, 224 insertions(+), 41 deletions(-) rename backend/app/{services => core}/scoring_caliber.py (100%) rename backend/app/{services => core}/tag_normalization.py (100%) create mode 100644 backend/tests/test_check_layering.py diff --git a/AGENTS.md b/AGENTS.md index eec3c28a..b3a7145d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,14 +60,19 @@ api/v1/ ──► services/ ──► repositories/ ──► models/ ──► `IntegrityError` 等异常类。 **机器强制的边界**:`make layering`(等价于 `cd backend && uv run python -scripts/check_layering.py`,CI 作业 `backend-layering`)只作用于 `app/api/v1/`,且只认两条: -禁止 `import sqlalchemy`(`sqlalchemy.ext.asyncio` / `sqlalchemy.exc` 除外),禁止直接写 -`select/insert/update/delete` 构造与 `db.execute/db.add/db.scalars/db.scalar`。上表其余禁止项 -它查不出——`from app.models import ` 与 api 层合法要用的 Enum、依赖注入同模块, -自动判定会误报,只能人工看。`app/mcp/` 是与 `api/v1` 平级的入口层,不在机器检查范围内, -按上表 api 行的纪律人工把关(当前只调 services 与 `DailyReportRepository`;OAuth -provider 的业务编排在 `app/services/mcp_oauth_service.py`,同意页 REST 端点在 -`app/api/v1/mcp_oauth.py`)。 +scripts/check_layering.py`,CI 作业 `backend-layering`)作用于两个目录,共三条规则。 +`app/api/v1/` 认两条:禁止 `import sqlalchemy`(`sqlalchemy.ext.asyncio` / +`sqlalchemy.exc` 除外),禁止直接写 `select/insert/update/delete` 构造与 +`db.execute/db.add/db.scalars/db.scalar`。`app/repositories/` 认一条(#113): +禁止 import `app.services.*`(函数内局部 import 同样抓);唯一存量豁免是 +`analysis_repo.py` 的两个评分编排 import(评分上移重构单 #122 清空, +allowlist 只减不增)。跨层共享的口径常量(`scoring_caliber.py`、 +`tag_normalization.py`)已落 `app/core/`,repositories 从 core 取用是合法向下依赖。 +上表其余禁止项机器查不出——`from app.models import ` 与 api 层合法要用的 +Enum、依赖注入同模块,自动判定会误报,只能人工看。`app/mcp/` 是与 `api/v1` 平级的 +入口层,不在机器检查范围内,按上表 api 行的纪律人工把关(当前只调 services 与 +`DailyReportRepository`;OAuth provider 的业务编排在 +`app/services/mcp_oauth_service.py`,同意页 REST 端点在 `app/api/v1/mcp_oauth.py`)。 ### 2.3 迁移与高风险变更 diff --git a/Makefile b/Makefile index faac568f..beb0307c 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ lint: lint-backend layering lint-frontend ## 完整质量门(ruff + 分层 + lint-backend: ## 后端 ruff 全量检查 + 格式检查(手动质量门) cd backend && uv run ruff check . && uv run ruff format --check . -layering: ## 分层纪律检查:api/v1 禁止直接 ORM 查询 / 禁用 import +layering: ## 分层纪律检查:api/v1 禁 ORM 查询;repositories 禁 services 逆向 import cd backend && uv run python scripts/check_layering.py lint-frontend: ## 前端类型检查(AGENTS.md 首选,比 npm run lint 稳) diff --git a/backend/app/api/v1/contents.py b/backend/app/api/v1/contents.py index b33fd13a..e02331d6 100644 --- a/backend/app/api/v1/contents.py +++ b/backend/app/api/v1/contents.py @@ -147,9 +147,7 @@ async def list_contents( tag: str | None = Query(None, description="按标签筛选(规范化键,大小写不敏感;命中 content 或任一分析的标签)"), include_trend_sources: bool = Query(False, description="Include榜单/趋势源 such as DouyinHot"), hours: int | None = Query(None, description="Time range in hours, e.g. 24, 48, 168"), - sort_by: str = Query( - "created_at", pattern=r"^(created_at|published_at|crawled_at|curation_score)$" - ), + sort_by: str = Query("created_at", pattern=r"^(created_at|published_at|crawled_at|curation_score)$"), sort_order: str = Query("desc", pattern=r"^(asc|desc)$"), admin_view: bool = Query(False, description="Return management fields; admin only"), current_user: User | None = Depends(get_optional_current_user), @@ -416,9 +414,9 @@ async def content_tag_facets( from collections import Counter from datetime import timedelta + from app.core.tag_normalization import normalize_tag_list from app.repositories.ignored_repo import IgnoredRepo from app.services.json_cache import get_cached_json, set_cached_json - from app.services.tag_normalization import normalize_tag_list scope_q = (q or "").strip() cache_key = ( diff --git a/backend/app/services/scoring_caliber.py b/backend/app/core/scoring_caliber.py similarity index 100% rename from backend/app/services/scoring_caliber.py rename to backend/app/core/scoring_caliber.py diff --git a/backend/app/services/tag_normalization.py b/backend/app/core/tag_normalization.py similarity index 100% rename from backend/app/services/tag_normalization.py rename to backend/app/core/tag_normalization.py diff --git a/backend/app/repositories/content_repo.py b/backend/app/repositories/content_repo.py index f93031c9..015f66d8 100644 --- a/backend/app/repositories/content_repo.py +++ b/backend/app/repositories/content_repo.py @@ -594,8 +594,8 @@ async def list_paginated_with_analyses( ) # ── 服务端推荐等级 / 标签筛选 ──────────────────────────────── + from app.core.tag_normalization import normalize_tag_key from app.models.analysis import AiAnalysis - from app.services.tag_normalization import normalize_tag_key # 两者口径与前端展示保持一致:等级取最新一条分析;标签命中 # content.tags 或任一分析的 tags(对应前端 getItemTags 的合并)。 @@ -866,8 +866,8 @@ async def list_for_today_picks( (``owner_user_id IS NULL``) or content owned by that user. ``None`` means no visibility filter (batch/internal callers). """ + from app.core.scoring_caliber import CONFIG as SCORING_CONFIG from app.models.analysis import AiAnalysis - from app.services.scoring_engine import CONFIG as SCORING_CONFIG cutoff = naive_utc_now() - timedelta(hours=hours) risk_threshold = float(SCORING_CONFIG["risk_threshold"]) @@ -908,8 +908,8 @@ async def list_for_report_window( 口径与 today-picks / DuckDB query_today_picks 对齐:剔除事件附属内容 与已忽略内容 (``exclude_ids``)。 """ + from app.core.scoring_caliber import CONFIG as SCORING_CONFIG from app.models.analysis import AiAnalysis - from app.services.scoring_engine import CONFIG as SCORING_CONFIG risk_threshold = float(SCORING_CONFIG["risk_threshold"]) latest_analysis_id = self._latest_analysis_id_subquery(AiAnalysis) diff --git a/backend/app/services/analysis.py b/backend/app/services/analysis.py index 4cc6a1eb..f95212c5 100644 --- a/backend/app/services/analysis.py +++ b/backend/app/services/analysis.py @@ -324,8 +324,8 @@ def _build_analysis_record( 标签与推荐等级在此收口(幂等):lite 级联与本地回退路径不经过 _normalize_analysis_result,统一在构造处保证存储口径一致。 """ + from app.core.tag_normalization import normalize_tag_list from app.services.recommendation_level import classify_recommendation_level - from app.services.tag_normalization import normalize_tag_list scores = result.get("scores", {}) curation = result.get("curation", {}) diff --git a/backend/app/services/analysis_normalize.py b/backend/app/services/analysis_normalize.py index da105b3a..3725557c 100644 --- a/backend/app/services/analysis_normalize.py +++ b/backend/app/services/analysis_normalize.py @@ -20,8 +20,8 @@ import json from typing import Any +from app.core.tag_normalization import normalize_tag_list from app.services.recommendation_level import classify_recommendation_level -from app.services.tag_normalization import normalize_tag_list def _detect_lang(title: str, content: str) -> str: diff --git a/backend/app/services/content_pipeline.py b/backend/app/services/content_pipeline.py index 12240551..3188a59d 100644 --- a/backend/app/services/content_pipeline.py +++ b/backend/app/services/content_pipeline.py @@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings +from app.core.tag_normalization import normalize_tag_list from app.models.content import ContentItem, ContentStatus from app.models.source import Source, SourceStatus, SourceType @@ -31,7 +32,6 @@ from app.services.llm_pre_filter import apply_pre_filter from app.services.scraper_http import build_scraper_client_kwargs from app.services.scrapers import get_scraper_cls -from app.services.tag_normalization import normalize_tag_list from app.utils.url_safety import UnsafeUrlError, ensure_public_hostname logger = logging.getLogger(__name__) diff --git a/backend/app/services/llm/prompts/analysis.py b/backend/app/services/llm/prompts/analysis.py index 6997a101..4e0dec00 100644 --- a/backend/app/services/llm/prompts/analysis.py +++ b/backend/app/services/llm/prompts/analysis.py @@ -4,11 +4,11 @@ Used by: - app.services.analysis.analyze_content -评分口径(加权/门槛/风险扣分)一律从 app.services.scoring_caliber 渲染, +评分口径(加权/门槛/风险扣分)一律从 app.core.scoring_caliber 渲染, 本文件不得手写数字——tests/test_scoring_caliber.py 锁死漂移(#110)。 """ -from app.services.scoring_caliber import ( +from app.core.scoring_caliber import ( curation_threshold_text, curation_weights_text, dimension_anchor_section, diff --git a/backend/app/services/scoring_engine.py b/backend/app/services/scoring_engine.py index 3f810344..4cf7b78d 100644 --- a/backend/app/services/scoring_engine.py +++ b/backend/app/services/scoring_engine.py @@ -19,16 +19,16 @@ import math from datetime import UTC, datetime +from app.core.scoring_caliber import CONFIG from app.schemas.scoring import ( ScoreBreakdownResponse, ScoreResultItem, ScoringRequestItem, ScoringResponse, ) -from app.services.scoring_caliber import CONFIG # ── Tunable configuration ──────────────────────────────────────────── -# 唯一声明处已迁至 app/services/scoring_caliber.py(#110):prompt 教学与 +# 唯一声明处为 app/core/scoring_caliber.py(#110,#113):prompt 教学与 # picks 查询共享同一份口径,tests/test_scoring_caliber.py 锁死漂移。 diff --git a/backend/scripts/check_layering.py b/backend/scripts/check_layering.py index 8bb9ff1c..bcfd3be8 100644 --- a/backend/scripts/check_layering.py +++ b/backend/scripts/check_layering.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """分层纪律的机器强制检查(对应 AGENTS.md「Layering Discipline」)。 -只机器化 AGENTS.md 里*符号级、可无歧义判定*的两条禁止项,作用于 `app/api/v1/`: +机器化 AGENTS.md 里*符号级、可无歧义判定*的禁止项,作用于两个目录: + +**app/api/v1/**(两条): 1. 禁止 import sqlalchemy —— 例外:`sqlalchemy.ext.asyncio`(AsyncSession 类型注解) 与 `sqlalchemy.exc`(IntegrityError / OperationalError 等异常类,供 try/except)。 @@ -9,13 +11,20 @@ 以及 `db.execute(...) / db.add(...) / db.scalars(...) / db.scalar(...)`。 这些必须下沉到 `repositories/`。 +**app/repositories/**(一条,#113): + +3. 禁止 import `app.services.*` —— 依赖方向 repositories→services 逆向 + (函数内局部 import 同样抓:#113 的违例全部藏在函数体内)。 + 已知存量豁免走 `REPO_SERVICES_IMPORT_ALLOWLIST`(按文件+模块粒度, + 引用重构 issue,棘轮式收缩,只减不增)。 + **不检查**的项(因为是符号级且和允许项同模块,自动判定会大量误报): `from app.models import ` 与 `` 混在 Enum / User 依赖注入里的情况, 仍靠 AGENTS.md 2.2 分层表「禁止」列的人工评审兜底。 用法: - python scripts/check_layering.py # 检查默认目录 - python scripts/check_layering.py app/api/v1 # 指定目录 + python scripts/check_layering.py # 检查 api/v1 + repositories + python scripts/check_layering.py app/api/v1 # 只检查指定目录(api 规则) 命中违规时打印文件:行 + 说明并以退出码 1 结束,供 CI 阻断。 """ @@ -28,11 +37,20 @@ # 检查目标目录(相对 backend/)。 API_DIR = Path("app/api/v1") +REPO_DIR = Path("app/repositories") # 刻意豁免的文件名(分层的合法低层边界)。 # _db_write.py 不再需要豁免——SQLite PRAGMA 调用已随 SQLite 分支移除。 ALLOWLIST: set[str] = set() +# repositories→services 禁令的存量豁免(#113 收口时遗留,#122 重构后清空)。 +# 键 = 文件名,值 = 允许 import 的 app.services 模块前缀集合; +# 新增条目必须附带 issue 引号,且只允许删除、不允许新增(棘轮)。 +REPO_SERVICES_IMPORT_ALLOWLIST: dict[str, set[str]] = { + # analysis_repo 评分编排上移 services 的重构单:#122 + "analysis_repo.py": {"app.services.feedback_signal", "app.services.scoring_engine"}, +} + # 允许的 sqlalchemy 子模块前缀(值对象 / 类型注解 / 异常类)。 ALLOWED_SQLALCHEMY_PREFIXES = ("sqlalchemy.ext.asyncio", "sqlalchemy.exc") @@ -95,16 +113,54 @@ def check_file(path: Path) -> list[str]: return violations +def check_repo_services_imports(path: Path) -> list[str]: + """repositories 层文件:禁止 import app.services.*(allowlist 内的前缀除外)。 + + ast.walk 覆盖模块级与函数体内的 import——#113 的违例全部藏在函数体里, + 只查模块级会漏。 + """ + allowed_prefixes = REPO_SERVICES_IMPORT_ALLOWLIST.get(path.name, set()) + violations: list[str] = [] + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + def _violation(lineno: int, desc: str) -> None: + if any(desc == prefix or desc.startswith(prefix + ".") for prefix in allowed_prefixes): + return + violations.append( + f"{path}:{lineno}: 禁止 `{desc}`——repositories 层不得 import services" + f"(业务编排属于 service,口径常量在 app/core/;存量豁免只减不增)" + ) + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + module = node.module or "" + if module == "app.services" or module.startswith("app.services."): + _violation(node.lineno, module) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "app.services" or alias.name.startswith("app.services."): + _violation(node.lineno, alias.name) + + return violations + + def main(argv: list[str]) -> int: - target = Path(argv[1]) if len(argv) > 1 else API_DIR - if not target.exists(): - print(f"ERROR: 目录不存在: {target}(请在 backend/ 下运行)", file=sys.stderr) - return 2 + # legacy 单目录模式只跑 api 规则(保持既有用法兼容);默认双目录全查。 + targets = [(Path(argv[1]), "api")] if len(argv) > 1 else [(API_DIR, "api"), (REPO_DIR, "repo")] all_violations: list[str] = [] - files = sorted(p for p in target.glob("*.py") if p.name not in ALLOWLIST) - for path in files: - all_violations.extend(check_file(path)) + file_count = 0 + for target, kind in targets: + if not target.exists(): + print(f"ERROR: 目录不存在: {target}(请在 backend/ 下运行)", file=sys.stderr) + return 2 + files = sorted(p for p in target.glob("*.py") if p.name not in ALLOWLIST) + file_count += len(files) + for path in files: + if kind == "api": + all_violations.extend(check_file(path)) + else: + all_violations.extend(check_repo_services_imports(path)) if all_violations: print("分层违规(详见 AGENTS.md「Layering Discipline」):", file=sys.stderr) @@ -116,7 +172,8 @@ def main(argv: list[str]) -> int: ) return 1 - print(f"分层检查通过:{len(files)} 个 api/v1 文件无直接 ORM 查询 / 禁用 import。") + scopes = " + ".join(str(t) for t, _ in targets) + print(f"分层检查通过:{scopes} 共 {file_count} 个文件,无禁用 import / 逆向依赖。") return 0 diff --git a/backend/scripts/scoring_anchor_ab.py b/backend/scripts/scoring_anchor_ab.py index 5eb2c551..89f28040 100644 --- a/backend/scripts/scoring_anchor_ab.py +++ b/backend/scripts/scoring_anchor_ab.py @@ -33,11 +33,11 @@ from sqlalchemy import or_, select # noqa: E402 from app.core.database import async_session # noqa: E402 +from app.core.scoring_caliber import CONFIG, dimension_anchor_section # noqa: E402 from app.models.content import ContentItem # noqa: E402 from app.services.analysis import _select_analysis_prompts # noqa: E402 from app.services.llm import call_llm_json # noqa: E402 from app.services.llm.prompts.analysis import ANALYSIS_PROMPT_EN # noqa: E402 -from app.services.scoring_caliber import CONFIG, dimension_anchor_section # noqa: E402 from app.utils.prompt_safety import sanitize_prompt_input # noqa: E402 logger = logging.getLogger("scoring_anchor_ab") diff --git a/backend/tests/test_check_layering.py b/backend/tests/test_check_layering.py new file mode 100644 index 00000000..120beadb --- /dev/null +++ b/backend/tests/test_check_layering.py @@ -0,0 +1,123 @@ +"""check_layering.py 的回归测试:repositories→services 禁令自身的正确性机器化。 + +#113 落地的门禁。它一旦误报,agent 会学会忽略检查结果;它一旦漏报 +(比如只查模块级 import、漏掉函数体内的局部 import——#113 的违例全部藏在 +函数体里),逆向依赖就回到静默存续。两者都比没有这道门禁更糟,用测试钉住。 + +用例分三组: +- **应杀**:合成违例文件(模块级 / 函数内局部 / `import app.services.x` 三形态) + 必须被抓到。 +- **豁免**:allowlist 内的前缀放行、前缀外的仍杀(棘轮语义——豁免是 + 模块粒度不是文件粒度,analysis_repo 若新增第三个 services 依赖照样红)。 +- **契约**:对真实仓库默认调用退出码为 0(CI 依赖命令行 + 退出码契约)。 + +本文件是纯 stdlib + subprocess,不碰数据库(外层 conftest 的 DB fixture 与它无关)。 +背景:#113(范围 2→5 处的取证)、#122(allowlist 清空的重构单)。 +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = BACKEND_ROOT / "scripts" / "check_layering.py" + + +def run_script(*args: str) -> tuple[int, str]: + """跑命令行契约(退出码 + 输出),cwd 固定 backend/ 让相对目录生效。""" + proc = subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + cwd=str(BACKEND_ROOT), + ) + return proc.returncode, proc.stdout + proc.stderr + + +def load_cl(): + """按路径加载 scripts/check_layering.py(scripts 不是包)。""" + import importlib.util + + spec = importlib.util.spec_from_file_location("check_layering_under_test", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def cl(): + return load_cl() + + +def repo_violations(tmp_path: Path, source: str, cl) -> list[str]: + """写入临时文件跑 check_repo_services_imports。""" + target = tmp_path / "some_repo.py" + target.write_text(source, encoding="utf-8") + return cl.check_repo_services_imports(target) + + +# ── 应杀 ── + + +def test_module_level_from_services_is_caught(cl, tmp_path: Path) -> None: + src = "from app.services.scoring_engine import score_items\n" + assert repo_violations(tmp_path, src, cl) + + +def test_function_local_from_services_is_caught(cl, tmp_path: Path) -> None: + """#113 的违例形态:藏在函数体内的局部 import。""" + src = ( + "def f():\n" + " from app.services.scoring_engine import CONFIG as SCORING_CONFIG\n" + " return SCORING_CONFIG\n" + ) + assert repo_violations(tmp_path, src, cl) + + +def test_plain_import_services_is_caught(cl, tmp_path: Path) -> None: + src = "import app.services.feedback_signal\n" + assert repo_violations(tmp_path, src, cl) + + +def test_non_services_import_is_fine(cl, tmp_path: Path) -> None: + """core/models/repositories 内部依赖不受影响。""" + src = ( + "from app.core.scoring_caliber import CONFIG\n" + "from app.core.tag_normalization import normalize_tag_key\n" + "from app.repositories.base import BaseRepository\n" + ) + assert repo_violations(tmp_path, src, cl) == [] + + +# ── 豁免(棘轮语义)── + + +def test_allowlisted_module_passes_and_prefix_escape_is_caught(cl, tmp_path: Path) -> None: + """豁免按 文件名+模块前缀 生效:allowlist 外的 services 依赖仍然杀。""" + allowed = "from app.services.scoring_engine import score_items\n" + escaped = "from app.services.duckdb_service import run_query\n" + # 同名文件才走 analysis_repo 的豁免 + target = tmp_path / "analysis_repo.py" + target.write_text(allowed + escaped, encoding="utf-8") + violations = cl.check_repo_services_imports(target) + assert len(violations) == 1 + assert "duckdb_service" in violations[0] + + +# ── 契约 ── + + +def test_real_repo_default_invocation_passes() -> None: + """真实仓库默认调用(api/v1 + repositories)必须退出码 0。""" + code, out = run_script() + assert code == 0, f"真实仓库分层检查未通过:\n{out}" + + +def test_allowlist_only_shrinks() -> None: + """allowlist 只减不增:当前仅剩 analysis_repo 一条(#122 清空)。""" + cl = load_cl() + assert set(cl.REPO_SERVICES_IMPORT_ALLOWLIST) == {"analysis_repo.py"} diff --git a/backend/tests/test_scoring_anchor_ab.py b/backend/tests/test_scoring_anchor_ab.py index fbdf884f..91cf2992 100644 --- a/backend/tests/test_scoring_anchor_ab.py +++ b/backend/tests/test_scoring_anchor_ab.py @@ -10,8 +10,8 @@ import importlib.util import os +from app.core.scoring_caliber import dimension_anchor_section from app.services.llm.prompts.analysis import ANALYSIS_PROMPT, ANALYSIS_PROMPT_EN -from app.services.scoring_caliber import dimension_anchor_section _SCRIPT_PATH = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), diff --git a/backend/tests/test_scoring_caliber.py b/backend/tests/test_scoring_caliber.py index 507ce310..f04051b2 100644 --- a/backend/tests/test_scoring_caliber.py +++ b/backend/tests/test_scoring_caliber.py @@ -14,9 +14,7 @@ import inspect import re -from app.services import scoring_engine -from app.services.llm.prompts import analysis as analysis_prompts -from app.services.scoring_caliber import ( +from app.core.scoring_caliber import ( ANCHOR_BANDS, CONFIG, DIMENSION_ANCHORS_EN, @@ -26,6 +24,8 @@ dimension_anchor_lines, dimension_anchor_section, ) +from app.services import scoring_engine +from app.services.llm.prompts import analysis as analysis_prompts SIX_DIM_KEYS = ( "w_info_density", diff --git a/backend/tests/test_tag_normalization.py b/backend/tests/test_tag_normalization.py index d128e9ae..f06d80bb 100644 --- a/backend/tests/test_tag_normalization.py +++ b/backend/tests/test_tag_normalization.py @@ -1,8 +1,8 @@ -"""标签规范化(services/tag_normalization.py)单元测试。""" +"""标签规范化(core/tag_normalization.py)单元测试。""" from __future__ import annotations -from app.services.tag_normalization import normalize_tag_key, normalize_tag_list, split_compound_tags +from app.core.tag_normalization import normalize_tag_key, normalize_tag_list, split_compound_tags def test_split_compound_tags_variants(): From 7e3dab7356579ca0eb2fe56f2fd5064505a51e88 Mon Sep 17 00:00:00 2001 From: fxbin Date: Sun, 18 Oct 2026 23:46:25 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(scoring):=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E9=95=9C=E5=83=8F=E4=B8=8A=20CI=20=E9=97=A8?= =?UTF-8?q?=E7=A6=81=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 16 +++ AGENTS.md | 5 +- Makefile | 9 +- backend/scripts/check_scoring_mirror.py | 107 ++++++++++++++++++++ backend/tests/test_check_scoring_mirror.py | 111 +++++++++++++++++++++ frontend/src/lib/recommendation.ts | 11 +- 6 files changed, 249 insertions(+), 10 deletions(-) create mode 100644 backend/scripts/check_scoring_mirror.py create mode 100644 backend/tests/test_check_scoring_mirror.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd37810a..df3ff37e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,22 @@ jobs: - name: Check AGENTS.md references run: python scripts/check_agent_docs.py ../AGENTS.md + # ── 前后端评分口径一致性(#112)── + # 前端 recommendation.ts 镜像的两个门槛数字(82/45)与 + # app/core/scoring_caliber.py CONFIG 对比;#110 的学费不允许再交一次。 + scoring-mirror: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Check scoring caliber mirror + run: python scripts/check_scoring_mirror.py + # ── 依赖安全扫描 ── security-scan: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index b3a7145d..32daee68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,10 +110,11 @@ make test-backend # 或直接跑全量 - Backend tests: `cd backend && uv run python -m pytest -q` —— `-k` 是 pytest 的**全局**过滤器,`pytest a.py b.py -k x` 会同时筛两个文件,不限于 a.py;要只筛某个文件就分开跑(依赖由 uv 管理:`uv sync` 安装,事实源为 pyproject.toml + uv.lock) - Shell scripts: `bash -n