22-模块三-AI编码规范体系 第22讲-模块实战 - 为 CodeSentinel 构建 AGENTS.md 管理与规则执行引擎

4 阅读25分钟

模块三-AI编码规范体系 | 第22讲:模块实战 - 为 CodeSentinel 构建 AGENTS.md 管理与规则执行引擎

本讲目标:把模块三的「规范解析 + 版本对象 + 混合执行 + 合规报告」收敛为一个可交付子系统;用 FastAPI 暴露标准管理接口,用 SQLAlchemy 持久化标准与结果;与模块二的审核聚合打通,实现「上传规范 → 提交代码评审 → 回写发现项 → 查询合规报告」的端到端链路,并用 Docker Compose 交付可运行环境。本讲强调可运行、可联调、可扩展三者兼顾。


开场:模块三不是「写文档」,而是「做平台子系统」

如果前两讲还停留在方法与演示脚本,本讲要把它们工程化为 CodeSentinel 的真实模块:规则管理子系统。它的用户不只是研发,还包括规范维护者、平台管理员、以及将来接入的内部代理与工作流引擎。你要交付的能力可以压缩成四句话:第一,项目维度的 AGENTS.md 可以创建、读取、更新、删除,并且每次变更都可追溯;第二,规范文本进入解析与索引流水线,生成可供执行的规则条目视图;第三,混合规则引擎对代码快照求值,输出结构化合规结果;第四,合规结果可以汇总为报告接口,并在需要时映射为模块二审核聚合中的 finding,实现治理闭环。

本讲刻意选择「与模块二松耦合、与平台强内聚」的集成方式:通过 HTTP 调用 POST /reviews/{review_id}/findings 把规范违规写回审核记录,而不是在代码里硬编码数据库细节。这样你可以在不同团队之间复用同一套标准服务实现,同时保持各自的审核工作流差异。这样你可以独立演进规则管理服务的部署形态,同时保持领域边界清晰。下面先给出模块三子系统在 CodeSentinel 总架构中的位置,再展开数据模型、接口流与完整实现。完成本讲后,你应能向团队演示一个可复用的最小治理闭环,而不是停留在概念层面。建议你在演示结尾明确说明:哪些能力是演示简化版,哪些能力需要在生产环境补齐。


全局视角:模块三子系统架构(Mermaid)

flowchart TB
  subgraph UI["客户端/CI/Agent"]
    C1["规范维护者"]
    C2["研发提交评审"]
  end

  subgraph M3["模块三:Standards & Rules"]
    API["FastAPI"]
    PS["ParserService\n解析+索引"]
    HE["HybridEvalService\n确定性+LLM"]
    RS["ReportingService"]
  end

  subgraph Data["持久化"]
    PG[("PostgreSQL")]
  end

  subgraph M2["模块二:Review 服务"]
    RV["Review API\n/reviews/*"]
  end

  C1 --> API
  C2 --> API
  API --> PS --> PG
  API --> HE --> PG
  API --> RS --> PG
  HE -->|可选 push_findings| RV

核心原理:为什么要把 CRUD、解析、执行、报告拆开

1. CRUD 是治理入口,解析是派生物

很多团队会把「保存 Markdown」当作终点。CodeSentinel 的正确终点是可执行的规则视图。因此数据库里既要存 content_md(真相来源),也要存 rule_index 表(派生数据)。派生数据允许重建,但必须能定位到某次解析版本,否则会出现「规则条目与原文不一致却无法解释」的问题。

2. 索引的意义:加速执行与支撑检索

当规则数量上升,全量扫描的成本会增加。索引层可以记录:规则类型、命名空间、关键词、关联路径模式。执行前先做裁剪,把明显无关的规则跳过。对于教学实现,我们用简化表结构表达这一思想;生产可以引入全文检索或向量检索。

3. 混合执行与事务边界

一次合规求值应产生 compliance_run 记录,再批量写入 compliance_result。如果中途失败,运行状态标记为 failed,避免半写入导致误判。推送到模块二 finding 的动作应当是可重试的:网络失败时不应丢失本地结果。

4. 与审核聚合的映射策略

建议映射规则:确定性 violated 且 severity 为 high → finding severity highmediummedium;LLM 且 confidence 低 → low。对于 skipped/error,默认不推送 finding,而是写入报告供平台处理。

5. Docker Compose 的定位

本讲 Compose 负责拉起 PostgreSQL 与 standards 服务。模块二的 review 服务可以一并启动,也可以用环境变量指向已存在的实例。关键是网络联通密钥隔离

6. 安全与最小权限

标准文本可能包含内部链接与策略描述,服务接口需要鉴权(本讲为聚焦核心省略完整 OAuth,仅保留扩展点说明)。生产必须补齐身份认证、审计日志与速率限制。

7. 端到端测试心智模型

测试路径:创建项目 → 上传 AGENTS.md → 触发 evaluate → 查询 run →(可选)验证 review findings 增加。该路径验证的不是「模型有多聪明」,而是平台链路是否可靠

8. 可演进路线

下一模块可以接入异步队列执行 LLM、接入对象存储保存大仓库快照、接入血缘系统关联规则版本与发布版本。本讲先把同步路径写正确。

9. 与 codesentinel-clean-lab 的对齐方式

模块二实验仓库提供了审核聚合与 finding 接口。本讲示例通过 REVIEW_SERVICE_URL 调用该接口,使你可以把 codesentinel-clean-lab 作为 review 侧运行实例,把本讲服务作为 standards 侧运行实例,形成最小双服务联调。

10. 失败场景清单

常见失败包括:数据库迁移未执行、review 服务不可达、LLM 凭证缺失导致主观规则 error、以及代码快照过大导致超时。平台应返回明确错误码,并在 compliance_run.error_message 记录摘要。

11. 幂等与重试的工程含义

持续集成往往会重试任务。若每次重试都生成新的合规运行记录,看板会被重复数据污染。生产环境应为求值接口设计幂等键,或在客户端使用稳定的 run_key 进行去重。推送 finding 到审核服务时,也应考虑去重键,避免同一条违规在评论里重复出现。

12. 从「同步求值」到「异步 worker」的切换条件

当规则数量、文件数量或语言模型调用次数超过阈值,同步 API 会拖垮网关线程池。切换信号包括:接口尾部延迟持续上升、队列堆积、以及客户端超时率上升。异步化后,API 只负责创建任务与返回 run_id,查询端提供轮询或订阅通知。

13. API 设计中的「项目」与「仓库」关系

教学示例用 Project 表示一个被治理对象。真实平台里它往往映射为「组织/仓库/分支策略」的组合。你可以把 slug 约定为 org/repo,把分支信息放进求值请求,或在规范版本指针中体现。关键是不要在领域模型里硬编码只有一种映射方式。

14. 解析器升级与索引重建

ParserService 升级后,旧索引可能与新解析不一致。平台应提供 POST /projects/{id}/standards/{sid}/reindex 管理接口,并在规范变更时自动触发重建。重建必须幂等,且应记录 parser_version 以便审计。


API 与领域流程:从上传到报告(Mermaid)

sequenceDiagram
  participant U as 用户/CI
  participant S as Standards API
  participant P as ParserService
  participant E as HybridEvalService
  participant D as PostgreSQL
  participant R as Review API

  U->>S: POST /projects/{id}/standards (AGENTS.md)
  S->>D: 写入 standard_documents 行
  S->>P: rebuild_index(standard_id)
  P->>D: 写入 rule_index 行
  U->>S: POST /projects/{id}/compliance/evaluate
  S->>E: evaluate(run, files, options)
  E->>D: 写入 compliance_run/results
  opt push_findings
    E->>R: POST /reviews/{id}/findings
  end
  U->>S: GET /compliance/runs/{run_id}
  S->>D: 查询汇总
  S-->>U: 报告 JSON

代码实战:完整可运行实现(FastAPI + SQLAlchemy + LangChain Fake LLM)

代码结构导读(读代码前先建立心智模型)

本讲实现刻意采用「单体服务 + 清晰分层」:路由层只负责参数校验与错误码;ParserService 只负责把 Markdown 变为 rule_index 行;HybridEvalService 只负责把规则行与代码快照映射为 compliance_resultsReviewClient 只负责与模块二 HTTP 边界交互。这样你在评审代码时可以用一条简单标准检验:每一层是否可以在不修改其他层的前提下单独测试。如果答案是肯定的,后续拆分微服务或引入消息队列都会更顺滑。

与模块二 AddFindingUseCase 的对应关系

模块二里,finding 的写入走领域方法 review.add_finding,HTTP 层对应 POST /reviews/{review_id}/findings。本讲不直接 import 模块二代码库,而是通过同样形状的 JSON 请求把规范违规「投射」为 finding。这样做的好处是:标准服务与审核服务可以不同语言、不同发布周期;坏处是:你需要维护一份轻量的「契约测试」,防止模块二接口字段变更导致推送失败。建议在平台层引入消费者驱动的契约校验,或在集成测试里固定一组最小用例每日运行。

Fake LLM 与真实模型的切换方式

教学代码使用 FakeListChatModel 以保证可重复与零密钥。切换到真实模型时,你只需要替换 build_fake_llm():例如返回 ChatOpenAI 或公司内网网关模型客户端,并在外层增加超时、重试与费用统计。记得把 prompt 版本与模型名写入 evidence 或单独审计表,否则合规报告无法复盘。

删除接口与级联策略

示例 delete_standard 直接删除规范行。生产环境更常见的是软删除:把 is_active=false,并保留历史版本供审计。若采用硬删除,要确保外键策略明确:rule_indexcompliance_results 要么级联删除,要么禁止删除仍有运行记录引用的规范版本。否则数据库会陷入不一致状态。

说明:请将下列文件放入同一目录 module3_standards_service/ 并执行 docker compose up --build。若本地无 Docker,可将 DATABASE_URL 指向本机 PostgreSQL,并直接运行 uvicorn app.main:app --port 8080

requirements.txt

fastapi>=0.110.0
uvicorn[standard]>=0.27.0
sqlalchemy>=2.0.0
psycopg[binary]>=3.1.0
pydantic>=2.6.0
httpx>=0.27.0
langchain-core>=0.2.0

app/db.py

from __future__ import annotations

import os

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker


DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg://codesentinel:codesentinel@db:5432/codesentinel")


class Base(DeclarativeBase):
    pass


engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)


def init_db() -> None:
    from app import models  # noqa: F401

    Base.metadata.create_all(bind=engine)


def get_session():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

app/models.py

from __future__ import annotations

import uuid
from datetime import datetime

from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db import Base


def _uuid() -> uuid.UUID:
    return uuid.uuid4()


class Project(Base):
    __tablename__ = "projects"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
    name: Mapped[str] = mapped_column(String(256), unique=True, index=True)
    slug: Mapped[str] = mapped_column(String(256), unique=True, index=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

    standards: Mapped[list[StandardDocument]] = relationship(back_populates="project")


class StandardDocument(Base):
    __tablename__ = "standard_documents"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
    project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), index=True)
    version_tag: Mapped[str] = mapped_column(String(64), index=True)
    content_md: Mapped[str] = mapped_column(Text)
    content_sha256: Mapped[str] = mapped_column(String(64), index=True)
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

    project: Mapped[Project] = relationship(back_populates="standards")
    rules: Mapped[list[RuleIndexRow]] = relationship(back_populates="standard")


class RuleIndexRow(Base):
    __tablename__ = "rule_index"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
    standard_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("standard_documents.id"), index=True)
    rule_key: Mapped[str] = mapped_column(String(128), index=True)
    namespace: Mapped[str] = mapped_column(String(512))
    text: Mapped[str] = mapped_column(Text)
    kind: Mapped[str] = mapped_column(String(64), index=True)
    meta_json: Mapped[str] = mapped_column(Text)

    standard: Mapped[StandardDocument] = relationship(back_populates="rules")


class ComplianceRun(Base):
    __tablename__ = "compliance_runs"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
    project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), index=True)
    standard_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("standard_documents.id"), index=True)
    review_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
    status: Mapped[str] = mapped_column(String(32), index=True)
    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))


class ComplianceResult(Base):
    __tablename__ = "compliance_results"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
    run_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("compliance_runs.id"), index=True)
    rule_key: Mapped[str] = mapped_column(String(128), index=True)
    status: Mapped[str] = mapped_column(String(32))
    severity: Mapped[str] = mapped_column(String(32))
    channel: Mapped[str] = mapped_column(String(32))
    message: Mapped[str] = mapped_column(Text)
    evidence_json: Mapped[str] = mapped_column(Text)

app/schemas.py

from __future__ import annotations

from datetime import datetime
from typing import List, Optional
from uuid import UUID

from pydantic import BaseModel, Field


class ProjectCreate(BaseModel):
    name: str = Field(min_length=1, max_length=256)
    slug: str = Field(min_length=1, max_length=256)


class ProjectOut(BaseModel):
    id: UUID
    name: str
    slug: str
    created_at: datetime


class StandardCreate(BaseModel):
    version_tag: str = Field(min_length=1, max_length=64)
    content_md: str = Field(min_length=1)


class StandardOut(BaseModel):
    id: UUID
    project_id: UUID
    version_tag: str
    content_sha256: str
    is_active: bool
    created_at: datetime


class FileSnapshotIn(BaseModel):
    path: str
    content: str


class EvaluateIn(BaseModel):
    standard_id: UUID
    files: List[FileSnapshotIn]
    review_id: Optional[str] = None
    push_findings: bool = False


class ComplianceResultOut(BaseModel):
    rule_key: str
    status: str
    severity: str
    channel: str
    message: str
    evidence: dict


class ComplianceRunOut(BaseModel):
    id: UUID
    project_id: UUID
    standard_id: UUID
    review_id: Optional[str]
    status: str
    error_message: Optional[str]
    created_at: datetime
    results: List[ComplianceResultOut]

app/services/parser_service.py

from __future__ import annotations

import hashlib
import json
import re
import uuid
from dataclasses import dataclass
from typing import Dict, List, Tuple

from sqlalchemy.orm import Session

from app.models import RuleIndexRow, StandardDocument


@dataclass(frozen=True)
class ParsedRule:
    rule_key: str
    namespace: str
    text: str
    kind: str
    meta: Dict[str, str]


class ParserService:
    _heading_re = re.compile(r"^(#{1,6})\s+(.*)$")
    _list_item_re = re.compile(r"^\s*[-*+]\s+(.*)$")
    _import_ban_re = re.compile(
        r"(禁止|不得|不要|never)\s+(?:从\s*)?[`\"]?([\w.]+)[`\"]?\s+(?:import|导入)",
        re.IGNORECASE,
    )

    def content_sha256(self, text: str) -> str:
        return hashlib.sha256(text.encode("utf-8")).hexdigest()

    def parse_rules(self, markdown: str) -> List[ParsedRule]:
        lines = markdown.splitlines()
        headings: List[Tuple[int, str]] = []
        sections: Dict[str, List[str]] = {}
        current = "ROOT"
        sections[current] = []

        for line in lines:
            m = self._heading_re.match(line)
            if m:
                level = len(m.group(1))
                title = m.group(2).strip()
                headings.append((level, title))
                current = " / ".join(t for _, t in headings)
                sections.setdefault(current, [])
                continue
            sections[current].append(line)

        rules: List[ParsedRule] = []
        for namespace, buf in sections.items():
            rules.extend(self._lines_to_rules(namespace, buf))
        return rules

    def _lines_to_rules(self, namespace: str, lines: List[str]) -> List[ParsedRule]:
        out: List[ParsedRule] = []
        paragraph: List[str] = []

        def flush_paragraph() -> None:
            nonlocal paragraph
            if not paragraph:
                return
            text = "\n".join(paragraph).strip()
            if text:
                out.append(self._make_rule(namespace, text))
            paragraph = []

        for line in lines:
            m = self._list_item_re.match(line)
            if m:
                flush_paragraph()
                out.append(self._make_rule(namespace, m.group(1).strip()))
            else:
                if line.strip() == "":
                    flush_paragraph()
                else:
                    paragraph.append(line)
        flush_paragraph()
        return out

    def _make_rule(self, namespace: str, text: str) -> ParsedRule:
        kind, meta = self._classify(text)
        key = str(uuid.uuid4())
        return ParsedRule(rule_key=key, namespace=namespace, text=text, kind=kind, meta=meta)

    def _classify(self, text: str) -> Tuple[str, Dict[str, str]]:
        m1 = self._import_ban_re.search(text)
        if m1:
            return "import_ban", {"banned_prefix": m1.group(2)}
        if "regex:" in text:
            m = re.search(r"regex:\s*(\S+)", text)
            if m:
                return "regex", {"pattern": m.group(1)}
        if any(k in text for k in ("整洁架构", "Clean Architecture", "遵循", "保持")):
            return "llm_subjective", {}
        return "unknown", {}

    def rebuild_index(self, db: Session, standard: StandardDocument) -> int:
        db.query(RuleIndexRow).filter(RuleIndexRow.standard_id == standard.id).delete()
        parsed = self.parse_rules(standard.content_md)
        for pr in parsed:
            db.add(
                RuleIndexRow(
                    standard_id=standard.id,
                    rule_key=pr.rule_key,
                    namespace=pr.namespace,
                    text=pr.text,
                    kind=pr.kind,
                    meta_json=json.dumps(pr.meta, ensure_ascii=False),
                )
            )
        db.commit()
        return len(parsed)

app/services/hybrid_eval.py

from __future__ import annotations

import ast
import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import List, Optional, Sequence

from sqlalchemy.orm import Session

from app.models import ComplianceResult, ComplianceRun, RuleIndexRow, StandardDocument
from app.schemas import FileSnapshotIn


@dataclass
class Verdict:
    rule_key: str
    status: str
    severity: str
    channel: str
    message: str
    evidence: dict


class DeterministicEngine:
    def evaluate(self, rule: RuleIndexRow, files: Sequence[FileSnapshotIn]) -> Verdict:
        meta = json.loads(rule.meta_json or "{}")
        if rule.kind == "import_ban":
            banned = meta.get("banned_prefix")
            if not banned:
                return Verdict(rule.rule_key, "skipped", "info", "deterministic", "缺少 banned_prefix", {})
            hits = self._scan_imports(banned, files)
            if hits:
                return Verdict(
                    rule.rule_key,
                    "violated",
                    "high",
                    "deterministic",
                    f"命中禁止导入前缀: {banned}",
                    {"hits": hits},
                )
            return Verdict(rule.rule_key, "passed", "info", "deterministic", "未发现禁止导入", {})

        if rule.kind == "regex":
            pattern = meta.get("pattern")
            if not pattern:
                return Verdict(rule.rule_key, "skipped", "info", "deterministic", "缺少 pattern", {})
            rx = re.compile(pattern)
            hits = []
            for f in files:
                for i, line in enumerate(f.content.splitlines(), start=1):
                    if rx.search(line):
                        hits.append({"path": f.path, "line": i, "text": line.strip()})
            if hits:
                return Verdict(
                    rule.rule_key,
                    "violated",
                    "medium",
                    "deterministic",
                    f"正则命中: {pattern}",
                    {"hits": hits[:50]},
                )
            return Verdict(rule.rule_key, "passed", "info", "deterministic", "正则未命中", {})

        if rule.kind == "unknown":
            return Verdict(rule.rule_key, "skipped", "info", "deterministic", "unknown 规则跳过", {})

        return Verdict(rule.rule_key, "skipped", "info", "deterministic", "非确定性通道处理", {})

    @staticmethod
    def _scan_imports(banned_prefix: str, files: Sequence[FileSnapshotIn]) -> List[dict]:
        hits: List[dict] = []
        banned_prefix = banned_prefix.rstrip(".")
        for f in files:
            if not f.path.endswith(".py"):
                continue
            try:
                tree = ast.parse(f.content, filename=f.path)
            except SyntaxError as exc:
                hits.append({"path": f.path, "line": exc.lineno or 0, "detail": f"syntax_error: {exc.msg}"})
                continue
            for node in ast.walk(tree):
                if isinstance(node, ast.Import):
                    for alias in node.names:
                        mod = alias.name
                        if mod == banned_prefix or mod.startswith(banned_prefix + "."):
                            hits.append({"path": f.path, "line": node.lineno, "import": f"import {mod}"})
                elif isinstance(node, ast.ImportFrom):
                    base = node.module or ""
                    if base == banned_prefix or base.startswith(banned_prefix + "."):
                        names = ", ".join(a.name for a in node.names)
                        hits.append({"path": f.path, "line": node.lineno, "import": f"from {base} import {names}"})
        return hits


class LLMRuleEvaluator:
    def __init__(self, runnable) -> None:
        self._runnable = runnable

    def evaluate(self, rule: RuleIndexRow, files: Sequence[FileSnapshotIn]) -> Verdict:
        payload = {
            "rule": {"key": rule.rule_key, "namespace": rule.namespace, "text": rule.text},
            "files": [{"path": f.path, "content": f.content[:6000]} for f in files],
        }
        prompt = (
            "你是 CodeSentinel。请仅依据规则与代码判断合规性,输出严格 JSON:"
            '{"compliant":true/false,"confidence":0-1,"severity":"low|medium|high",'
            '"reasoning":"中文","citations":[]}\n'
            f"输入:{json.dumps(payload, ensure_ascii=False)}"
        )
        try:
            text = self._runnable.invoke(prompt)
            text = text if isinstance(text, str) else str(text)
            data = json.loads(text)
        except Exception as exc:  # noqa: BLE001
            return Verdict(rule.rule_key, "error", "medium", "llm", f"LLM 评估失败: {exc}", {})

        ok = bool(data.get("compliant"))
        return Verdict(
            rule.rule_key,
            "passed" if ok else "violated",
            str(data.get("severity", "medium")),
            "llm",
            str(data.get("reasoning", "")),
            {"confidence": data.get("confidence", 0.0), "citations": data.get("citations", [])},
        )


def build_fake_llm():
    from langchain_core.language_models.fake import FakeListChatModel

    fixed = json.dumps(
        {
            "compliant": True,
            "confidence": 0.85,
            "severity": "low",
            "reasoning": "(FakeLLM)未观察到明显违背。",
            "citations": [],
        },
        ensure_ascii=False,
    )
    return FakeListChatModel(responses=[fixed])


class HybridEvalService:
    def __init__(self, db: Session) -> None:
        self._db = db
        self._det = DeterministicEngine()
        self._llm = LLMRuleEvaluator(build_fake_llm())

    def evaluate_standard(
        self,
        *,
        project_id,
        standard: StandardDocument,
        files: List[FileSnapshotIn],
        review_id: Optional[str],
    ) -> ComplianceRun:
        run = ComplianceRun(
            project_id=project_id,
            standard_id=standard.id,
            review_id=review_id,
            status="running",
            created_at=datetime.now(timezone.utc),
        )
        self._db.add(run)
        self._db.commit()
        self._db.refresh(run)

        rules = (
            self._db.query(RuleIndexRow)
            .filter(RuleIndexRow.standard_id == standard.id)
            .order_by(RuleIndexRow.namespace.asc())
            .all()
        )

        try:
            for rule in rules:
                if rule.kind in ("import_ban", "regex", "unknown"):
                    verdict = self._det.evaluate(rule, files)
                elif rule.kind == "llm_subjective":
                    verdict = self._llm.evaluate(rule, files)
                else:
                    verdict = self._det.evaluate(rule, files)

                self._db.add(
                    ComplianceResult(
                        run_id=run.id,
                        rule_key=verdict.rule_key,
                        status=verdict.status,
                        severity=verdict.severity,
                        channel=verdict.channel,
                        message=verdict.message,
                        evidence_json=json.dumps(verdict.evidence, ensure_ascii=False),
                    )
                )

            run.status = "completed"
            self._db.commit()
        except Exception as exc:  # noqa: BLE001
            run.status = "failed"
            run.error_message = str(exc)
            self._db.commit()

        return run

app/services/review_client.py

from __future__ import annotations

import os

import httpx


class ReviewClient:
    def __init__(self, base_url: str | None = None) -> None:
        self._base_url = (base_url or os.getenv("REVIEW_SERVICE_URL", "http://host.docker.internal:8000")).rstrip("/")

    def push_violations(self, review_id: str, rows: list) -> None:
        with httpx.Client(timeout=10.0) as client:
            for r in rows:
                if r.status != "violated":
                    continue
                sev = r.severity if r.severity in {"low", "medium", "high", "critical"} else "medium"
                msg = f"[standards:{r.channel}] {r.message}"
                resp = client.post(
                    f"{self._base_url}/reviews/{review_id}/findings",
                    json={"severity": sev, "message": msg},
                )
                resp.raise_for_status()

联调提示codesentinel-clean-lab 要求审核在 in_progress 状态才能添加 finding。请先调用 POST /reviews/{id}/start

app/main.py

from __future__ import annotations

import json
from datetime import datetime, timezone
from uuid import UUID

from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy.orm import Session

from app.db import get_session, init_db
from app.models import ComplianceResult, ComplianceRun, Project, StandardDocument
from app.schemas import (
    ComplianceResultOut,
    ComplianceRunOut,
    EvaluateIn,
    ProjectCreate,
    ProjectOut,
    StandardCreate,
    StandardOut,
)
from app.services.hybrid_eval import HybridEvalService
from app.services.parser_service import ParserService
from app.services.review_client import ReviewClient


app = FastAPI(title="CodeSentinel Standards Service", version="0.3.0")


@app.on_event("startup")
def _startup() -> None:
    init_db()


@app.post("/projects", response_model=ProjectOut, status_code=201)
def create_project(body: ProjectCreate, db: Session = Depends(get_session)) -> ProjectOut:
    if db.query(Project).filter(Project.slug == body.slug).first():
        raise HTTPException(status_code=409, detail="slug 已存在")
    p = Project(name=body.name, slug=body.slug, created_at=datetime.now(timezone.utc))
    db.add(p)
    db.commit()
    db.refresh(p)
    return ProjectOut(id=p.id, name=p.name, slug=p.slug, created_at=p.created_at)


@app.post("/projects/{project_id}/standards", response_model=StandardOut, status_code=201)
def create_standard(project_id: UUID, body: StandardCreate, db: Session = Depends(get_session)) -> StandardOut:
    p = db.get(Project, project_id)
    if p is None:
        raise HTTPException(status_code=404, detail="项目不存在")

    parser = ParserService()
    sha = parser.content_sha256(body.content_md)
    std = StandardDocument(
        project_id=p.id,
        version_tag=body.version_tag,
        content_md=body.content_md,
        content_sha256=sha,
        is_active=True,
        created_at=datetime.now(timezone.utc),
    )
    db.add(std)
    db.commit()
    db.refresh(std)

    parser.rebuild_index(db, std)
    return StandardOut(
        id=std.id,
        project_id=std.project_id,
        version_tag=std.version_tag,
        content_sha256=std.content_sha256,
        is_active=std.is_active,
        created_at=std.created_at,
    )


@app.get("/projects/{project_id}/standards/latest", response_model=StandardOut)
def latest_standard(project_id: UUID, db: Session = Depends(get_session)) -> StandardOut:
    std = (
        db.query(StandardDocument)
        .filter(StandardDocument.project_id == project_id, StandardDocument.is_active.is_(True))
        .order_by(StandardDocument.created_at.desc())
        .first()
    )
    if std is None:
        raise HTTPException(status_code=404, detail="暂无规范")
    return StandardOut(
        id=std.id,
        project_id=std.project_id,
        version_tag=std.version_tag,
        content_sha256=std.content_sha256,
        is_active=std.is_active,
        created_at=std.created_at,
    )


@app.delete("/projects/{project_id}/standards/{standard_id}", status_code=204)
def delete_standard(project_id: UUID, standard_id: UUID, db: Session = Depends(get_session)) -> None:
    std = db.get(StandardDocument, standard_id)
    if std is None or std.project_id != project_id:
        raise HTTPException(status_code=404, detail="规范不存在")
    db.delete(std)
    db.commit()


@app.post("/projects/{project_id}/compliance/evaluate", response_model=ComplianceRunOut, status_code=201)
def evaluate(project_id: UUID, body: EvaluateIn, db: Session = Depends(get_session)) -> ComplianceRunOut:
    p = db.get(Project, project_id)
    if p is None:
        raise HTTPException(status_code=404, detail="项目不存在")
    std = db.get(StandardDocument, body.standard_id)
    if std is None or std.project_id != project_id:
        raise HTTPException(status_code=404, detail="规范不存在")

    svc = HybridEvalService(db)
    run = svc.evaluate_standard(project_id=p.id, standard=std, files=body.files, review_id=body.review_id)

    if body.push_findings and body.review_id:
        rows = db.query(ComplianceResult).filter(ComplianceResult.run_id == run.id).all()
        try:
            ReviewClient().push_violations(body.review_id, rows)
        except Exception as exc:  # noqa: BLE001
            raise HTTPException(status_code=502, detail=f"推送 finding 失败: {exc}") from exc

    return _serialize_run(db, run.id)


@app.get("/compliance/runs/{run_id}", response_model=ComplianceRunOut)
def get_run(run_id: UUID, db: Session = Depends(get_session)) -> ComplianceRunOut:
    run = db.get(ComplianceRun, run_id)
    if run is None:
        raise HTTPException(status_code=404, detail="run 不存在")
    return _serialize_run(db, run.id)


def _serialize_run(db: Session, run_id: UUID) -> ComplianceRunOut:
    run = db.get(ComplianceRun, run_id)
    assert run is not None
    rows = db.query(ComplianceResult).filter(ComplianceResult.run_id == run_id).all()
    results: list[ComplianceResultOut] = []
    for r in rows:
        results.append(
            ComplianceResultOut(
                rule_key=r.rule_key,
                status=r.status,
                severity=r.severity,
                channel=r.channel,
                message=r.message,
                evidence=json.loads(r.evidence_json or "{}"),
            )
        )
    return ComplianceRunOut(
        id=run.id,
        project_id=run.project_id,
        standard_id=run.standard_id,
        review_id=run.review_id,
        status=run.status,
        error_message=run.error_message,
        created_at=run.created_at,
        results=results,
    )

Dockerfile

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY app /app/app
ENV PYTHONUNBUFFERED=1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

docker-compose.yml(模块三交付物)

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: codesentinel
      POSTGRES_PASSWORD: codesentinel
      POSTGRES_DB: codesentinel
    ports:
      - "5432:5432"

  standards:
    build: .
    environment:
      DATABASE_URL: postgresql+psycopg://codesentinel:codesentinel@db:5432/codesentinel
      REVIEW_SERVICE_URL: http://host.docker.internal:8000
    depends_on:
      - db
    ports:
      - "8080:8080"

端到端联调步骤(中文操作说明)

第一步,在 codesentinel-clean-lab 目录启动模块二审核服务(示例端口 8000),创建审核并启动:POST /reviews,随后 POST /reviews/{id}/start。第二步,构建并启动本讲 Compose:docker compose up --build。第三步,调用 POST /projects 创建项目。第四步,调用 POST /projects/{id}/standards 上传包含列表项规则的 AGENTS.md。第五步,调用 POST /projects/{id}/compliance/evaluate 传入 Python 文件快照,并设置 review_idpush_findings=true。第六步,回到审核服务 GET /reviews/{id} 验证 findings 是否出现带 [standards:deterministic] 前缀的消息。

若推送失败,优先检查审核状态是否为进行中,以及 Docker 容器能否访问 host.docker.internal。在 Linux 上可能需要改为宿主机真实 IP 或把 review 服务一并纳入 Compose 网络。


数据模型 ER 图(Mermaid)

erDiagram
  PROJECT ||--o{ STANDARD_DOCUMENT : owns
  STANDARD_DOCUMENT ||--o{ RULE_INDEX : indexes
  PROJECT ||--o{ COMPLIANCE_RUN : runs
  STANDARD_DOCUMENT ||--o{ COMPLIANCE_RUN : targets
  COMPLIANCE_RUN ||--o{ COMPLIANCE_RESULT : produces

  PROJECT {
    uuid id PK
    string name
    string slug
    timestamptz created_at
  }

  STANDARD_DOCUMENT {
    uuid id PK
    uuid project_id FK
    string version_tag
    text content_md
    string content_sha256
    boolean is_active
    timestamptz created_at
  }

  RULE_INDEX {
    uuid id PK
    uuid standard_id FK
    string rule_key
    string namespace
    text text
    string kind
    text meta_json
  }

  COMPLIANCE_RUN {
    uuid id PK
    uuid project_id FK
    uuid standard_id FK
    string review_id
    string status
    text error_message
    timestamptz created_at
  }

  COMPLIANCE_RESULT {
    uuid id PK
    uuid run_id FK
    string rule_key
    string status
    string severity
    string channel
    text message
    text evidence_json
  }

生产环境实战:上线清单与演进建议

  1. 迁移工具:示例使用 create_all,生产应使用 Alembic 管理迁移。
  2. 鉴权:为所有变更接口加 OAuth2 或 mTLS;机器令牌与人员令牌分域。
  3. 大仓库策略:不要直接把整个仓库内容送入接口;改为对象存储引用与 worker 异步求值。
  4. LLM 治理:为每次调用保存 modelprompt_version、输入哈希;对敏感代码脱敏。
  5. 幂等性:为 evaluate 设计 Idempotency-Key,避免 CI 重试造成重复 run。
  6. SLA:为 llm_subjective 设置超时与降级策略,避免阻塞 API。
  7. 观测性:对解析耗时、规则数量、违规分布建立指标与追踪。

多租户与数据隔离

当 CodeSentinel 服务多个组织时,Project 维度往往不够,需要引入 tenant_id 并在查询中强制过滤。否则会出现跨租户读取规范与合规结果的严重事故。隔离策略可以是行级权限、独立 schema、或独立数据库集群,选择取决于规模与合规要求。无论哪种,都要在 API 网关层统一注入租户上下文,而不是让每个工程师在路由里手写过滤条件。

备份与灾难恢复

标准文本与合规结果属于治理资产,备份策略应至少满足:每日全量、关键表更频繁快照、以及可验证的恢复演练。很多团队在事故时才发现备份不可恢复,原因包括:备份文件未加密导致无法使用、恢复脚本过期、以及跨版本迁移不兼容。建议把「恢复演练」做成季度例行任务,并把 RTO/RPO 写进服务等级目标。

配置管理与密钥轮换

DATABASE_URL 与模型调用密钥属于高敏配置,应使用密钥管理系统挂载,而不是写进镜像层。轮换时要考虑连接池重建与零停机发布。对于推送模块二的令牌,也要支持短期凭证与自动续期,避免长寿命密钥泄露后影响面过大。

合规报告的「可读性」与「可执行性」

报告接口不仅要给机器消费,还要给人类负责人阅读。建议在返回结构里增加 summary:按严重级别聚合计数、列出前 N 条违规、附上 ruleset_versionstandard_id。这样负责人在移动端也能快速判断是否需要拦截发布。与此同时,报告中的链接应指向内部文档与修复指南,把治理平台从「挑错工具」升级为「改进系统」。


本讲小结(Mermaid Mindmap)

mindmap
  root((第22讲\n模块实战))
    交付物
      CRUD API
      解析索引
      混合执行
      合规报告
    集成
      Review findings
      HTTP 边界
    数据
      标准真相
      派生规则
      运行与结果
    工程
      Compose
      迁移
      鉴权

思考题

  1. 如果把 evaluate 改为异步任务,你会如何设计状态查询与回调通知机制?
  2. 当同一项目同时存在多份活跃规范时,平台应如何选择「默认规范」与「灰度规范」?
  3. 如何避免推送 finding 造成的重复评论风暴?需要去重键吗?

下一讲预告

模块四将进入更智能的评审体验:把合规结果与代码变更影响、历史缺陷模式、以及团队风险偏好结合,构建可解释的评审助手与工作流自动化。让我们把模块三的成果当作可靠底座继续向前推进。


深度复盘:把子系统放进 CodeSentinel 路线图(补充阅读)

当你完成本讲的子系统,你已经具备了一个「规范治理微服务」的雏形:它有自己的数据模型、自己的发布形态、自己的扩展点。下一步通常不是继续堆功能,而是把它放进更大的平台路线图里做边界确认。第一,明确它是控制平面还是数据平面:标准文本与规则索引属于控制平面,代码快照求值更接近数据平面,后者往往需要对象存储与计算队列。第二,明确它与身份体系的绑定方式:哪些角色可以改规范、哪些角色只能读规范、哪些服务账号可以触发求值。第三,明确它与事件总线的关系:当规范版本发布,是否应广播事件让各业务仓库刷新缓存;当合规运行完成,是否应发送通知给负责人。

在架构评审时,你可能会被问到「为什么不用 Git 直接读文件而要存数据库」。答案是:Git 是真相来源之一,但平台需要查询、索引、权限、审计与跨仓库聚合,这些能力在纯 Git 读取模型下成本更高。更成熟的形态是 Git 仍为权威源,平台周期性或 webhook 同步,把内容写入数据库并记录提交哈希。本讲为了简化演示,直接把 Markdown 作为接口输入,你可以把它视为同步已完成之后的状态。

另一个常见问题是「规则解析要不要做成单独服务」。当解析成本高或需要多语言扩展时,拆分为独立 worker 是合理的;但在早期单团队场景,放在 API 服务内足够,关键是接口边界清晰:rebuild_index 应该是幂等的,并且能在规范更新后被重复调用。对于大规模规则条目,索引表要加合适索引字段,避免 evaluate 阶段全表扫描拖垮数据库。

与模块二的集成也可以从 HTTP 升级为消息队列:合规运行结束后发送事件,由 review 服务消费并决定是否写入。这样可以解耦峰值流量,也能在 review 服务不可用时暂存重试。无论选择同步还是异步,都要坚持一个原则:合规运行的本地结果永远先落库,推送外部系统失败不应把结果弄丢。

从团队推广角度,本讲交付的 Docker Compose 不只是练习,而是一个「给决策者看的 Demo」。你可以在五分钟内展示:规范更新、违规定位、审核联动。技术方案能否落地,往往取决于这种演示是否足够令人相信这不是概念验证,而是可持续演进的工程路径。当你把演示接上真实评审流程与真实规范仓库,你就完成了模块三的真正闭环。

为了让模块三与模块二长期共存,你还应该提前约定错误语义与重试策略:审核服务返回四零九、四二九、五零二时,标准服务分别如何处理?是否写入补偿队列?这些细节看似琐碎,却决定平台是否能在真实网络环境中稳定运行。最后,建议你为 standard_documents.content_sha256 建立与规范仓库标签的映射表,这样可以把合规结果与「规范版本管理」一讲完全串起来,形成跨讲一致的 CodeSentinel 数据故事。

当你把「管理接口 + 索引 + 混合执行 + 报告 + 外部联动」拼成一体,你就完成了模块三的交付定义:它不是附属脚本,而是平台能力的一部分。后续你可以逐步把第20讲的编排器插件化、把第21讲的版本指针文件接入创建项目流程、把本讲数据库字段与审计要求对齐。这样每一次迭代都有清晰锚点,而不会沦为堆接口。

课堂演示脚本:用最小数据跑通闭环(建议照抄练习)

你可以准备一份极简 AGENTS.md,其中包含三条列表项规则:一条禁止导入 requests,一条要求代码中不得出现 print( 的正则规则,一条描述整洁架构的主观规则。再准备一份故意违规的 app/demo.py,同时包含 import requestsprint(。创建项目后上传规范,立刻执行 evaluate,你会看到确定性通道给出明确证据;主观规则在 Fake 模型下通常返回通过,用于验证链路而非验证模型能力。随后把 push_findings 打开,观察模块二审核里的消息是否带上通道前缀,从视觉上强化「同一平台、多种来源」的治理体验。

常见集成问题排查手册(建议你贴到团队 Wiki)

如果数据库连接失败,优先检查容器网络与 DATABASE_URL 是否指向 db 服务主机名。如果表不存在,确认 startup 是否执行了 create_all,以及进程是否有建表权限。如果推送 finding 返回四零四,检查 review_id 是否拼写正确、review 服务是否运行在可达地址。如果返回四零九冲突,几乎总是审核状态未进入进行中。如果 LLM 输出无法解析,检查 prompt 是否要求严格 JSON,以及模型是否支持遵循格式。如果性能慢,先看规则条目数量与文件总大小,再看是否缺少数据库索引与连接池参数调优。

从教学代码到企业落地的「最小增量清单」

第一,加 Alembic 与初始迁移。第二,加 API Key 或 JWT 鉴权中间件。第三,把 evaluate 拆成同步查询与异步执行两条路径。第四,加管理后台只读页面展示最近十次合规运行。第五,把违规推送改为可配置策略:哪些规则推送、哪些只在报告里保留。完成这五步,你的子系统就具备试点推广条件。之后再考虑多语言解析器、外部静态分析器插件、以及与统一身份系统的深度集成。