diff --git a/.env.example b/.env.example index e3cd1cb..2fc89f2 100644 --- a/.env.example +++ b/.env.example @@ -49,3 +49,17 @@ LOG_SQL=false # 数据库表结构一致性校验(启动时校验 DDL 与数据库是否一致) DB_SCHEMA_CHECK_ENABLED=true DB_SCHEMA_CHECK_FAIL_FAST=true + +# AI 失败分析(AIFA):main_module→仓库 YAML 绝对路径,空则 repo_hint 为空(模板见 config/module_repo_mapping.yaml.example) +AI_MODULE_REPO_MAPPING_PATH= + +# AI 失败分析(AIFA)代理配置 +# AIFA_BASE_URL: AIFA 服务地址(不含 /v1/analyze) +AIFA_BASE_URL=http://127.0.0.1:8080 +# AIFA_INTERNAL_TOKEN: dt-report 调用 AIFA 的内部 token(需与 AIFA 侧一致) +AIFA_INTERNAL_TOKEN= +# A5 限流:单 history_id 1 分钟最多 10 次(可通过环境变量覆盖) +AI_ANALYZE_RATE_LIMIT_WINDOW_SECONDS=60 +AI_ANALYZE_RATE_LIMIT_MAX_REQUESTS=10 +# dt-report -> AIFA 请求超时(秒) +AI_ANALYZE_TIMEOUT_SECONDS=180 diff --git a/ai-failure-analyzer/.env.example b/ai-failure-analyzer/.env.example new file mode 100644 index 0000000..980e557 --- /dev/null +++ b/ai-failure-analyzer/.env.example @@ -0,0 +1,30 @@ +# 内部 Service Token(dt-report 或脚本调用 AIFA 时使用;禁止提交真实值) +AIFA_INTERNAL_TOKEN= + +# LLM(OpenAI 兼容协议);Mock 模式下可不填 +AIFA_LLM_BASE_URL= +AIFA_LLM_API_KEY= +AIFA_LLM_MODEL=gpt-4o-mini + +# 设为 1 或 true 时不调用外网 LLM,返回固定 fixture(CI / 本地无密钥) +AIFA_LLM_MOCK= + +# 监听端口(uvicorn 启动时可用,见 README) +AIFA_PORT=8080 + +# CodeHub(B5) +AIFA_CODEHUB_BASE_URL= +AIFA_CODEHUB_TOKEN= +AIFA_CODEHUB_CONNECT_TIMEOUT_SECONDS=3 +AIFA_CODEHUB_READ_TIMEOUT_SECONDS=15 +AIFA_CODEHUB_LIST_LIMIT=30 +AIFA_CODEHUB_DIFF_MAX_LINES=500 +AIFA_CODEHUB_DIFF_TOP_N=5 +AIFA_CODEHUB_FALLBACK_WINDOW_DAYS=7 + +# C4:观测与成本 +AIFA_MAX_TOKENS_PER_REQUEST=80000 +AIFA_PRICE_PER_1K_INPUT=0 +AIFA_PRICE_PER_1K_OUTPUT=0 +AIFA_TRACE_LOG_PATH=trace.log +AIFA_MAX_CONCURRENT_ANALYSES=8 diff --git a/ai-failure-analyzer/.gitignore b/ai-failure-analyzer/.gitignore new file mode 100644 index 0000000..18f3ad0 --- /dev/null +++ b/ai-failure-analyzer/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +*.egg-info/ +dist/ +build/ diff --git a/ai-failure-analyzer/Dockerfile b/ai-failure-analyzer/Dockerfile new file mode 100644 index 0000000..a427222 --- /dev/null +++ b/ai-failure-analyzer/Dockerfile @@ -0,0 +1,62 @@ +# AIFA:与 dt-report 使用相同基础镜像(ubuntu:20.04)、同一 docker/sources.list、同一套 apt 安装 Python 的方式 +#(系统 python3 为 focal 自带版本,当前为 3.8.x;应用代码兼容 3.8+,见 pyproject.toml)。 +# +# 构建必须在仓库根目录执行: +# docker build -f ai-failure-analyzer/Dockerfile -t aifa:a1 . +# +# 可选 build-arg(与根目录 Dockerfile 对齐):HTTP_PROXY、HTTPS_PROXY、NO_PROXY、PIP_INDEX_URL、PIP_TRUSTED_HOST + +FROM ubuntu:20.04 + +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG PIP_INDEX_URL +ARG PIP_TRUSTED_HOST + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +COPY docker/sources.list /etc/apt/sources.list + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +RUN python3 -m venv /opt/venv + +ENV PATH="/opt/venv/bin:$PATH" + +WORKDIR /app + +COPY ai-failure-analyzer/requirements.txt . +RUN PIP_TRUST=() \ + && if [ -n "${PIP_TRUSTED_HOST}" ]; then PIP_TRUST=(--trusted-host "${PIP_TRUSTED_HOST}"); fi \ + && if [ -n "${PIP_INDEX_URL}" ]; then \ + echo "[docker build] 使用内网 PyPI: ${PIP_INDEX_URL}"; \ + pip install --no-cache-dir --upgrade --default-timeout=180 --retries=5 "${PIP_TRUST[@]}" -i "${PIP_INDEX_URL}" "pip<25"; \ + pip install --no-cache-dir --default-timeout=180 --retries=5 "${PIP_TRUST[@]}" -i "${PIP_INDEX_URL}" -r requirements.txt; \ + else \ + echo "[docker build] 使用默认 PyPI(需容器可访问外网或代理)"; \ + pip install --no-cache-dir --upgrade --default-timeout=180 --retries=5 "pip<25"; \ + pip install --no-cache-dir --default-timeout=180 --retries=5 -r requirements.txt; \ + fi + +COPY ai-failure-analyzer/pyproject.toml . +COPY ai-failure-analyzer/ai_failure_analyzer ./ai_failure_analyzer + +RUN pip install --no-cache-dir . + +EXPOSE 8080 + +CMD ["uvicorn", "ai_failure_analyzer.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/ai-failure-analyzer/README.md b/ai-failure-analyzer/README.md new file mode 100644 index 0000000..c83d99c --- /dev/null +++ b/ai-failure-analyzer/README.md @@ -0,0 +1,116 @@ +# ai-failure-analyzer(AIFA)Phase A1 + +独立服务:失败用例 AI 辅助归因(当前已支持 **Plan / Act / Synthesize 三阶段主循环**,并具备 B1 报告/截图工具调用能力)。 + +规格说明: +- [docs/superpowers/specs/aifa-phase-a1-service-spec.md](../docs/superpowers/specs/aifa-phase-a1-service-spec.md) +- [docs/superpowers/specs/aifa-phase-b1-report-screenshot-tools-spec.md](../docs/superpowers/specs/aifa-phase-b1-report-screenshot-tools-spec.md) +- [docs/superpowers/specs/aifa-phase-b2-agent-three-stage-spec.md](../docs/superpowers/specs/aifa-phase-b2-agent-three-stage-spec.md) + +## 环境要求 + +- **Python 3.8+**(与 `pyproject.toml` 中 `requires-python` 一致;便于与 dt-report 容器同为 3.8 或本地 3.10/3.11)。 +- 技术选型文档仍推荐独立服务使用 **3.11**;若环境与镜像暂为 **3.8**,代码层面已兼容,行为与接口不变。 + +## 安装 + +```bash +cd ai-failure-analyzer +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade 'setuptools>=68' pip wheel +pip install -e ".[dev]" +``` + +若本机 `pip install -e` 报 PEP 660 相关错误,可改用**非 editable**: + +```bash +pip install -r requirements-dev.txt +PYTHONPATH=. pytest -q +``` + +或仅生产依赖: + +```bash +pip install -r requirements.txt +pip install . +``` + +## 环境变量 + +复制 `.env.example` 为 `.env` 并按需填写。键名说明见 `.env.example`。 + +- **必须(生产)**:`AIFA_INTERNAL_TOKEN` +- **真实 LLM**:`AIFA_LLM_BASE_URL`、`AIFA_LLM_API_KEY`、`AIFA_LLM_MODEL`(Mock 模式下可不填) +- **CI / 无密钥**:`AIFA_LLM_MOCK=1` +- **CodeHub(B5)**:`AIFA_CODEHUB_BASE_URL`、`AIFA_CODEHUB_TOKEN`(未配置时自动降级跳过 code_blame) + +## 启动 + +```bash +cd ai-failure-analyzer +source .venv/bin/activate +export AIFA_INTERNAL_TOKEN="dev-only-change-me" +export AIFA_LLM_MOCK=1 +uvicorn ai_failure_analyzer.main:app --host 0.0.0.0 --port 8080 +``` + +## 健康检查 + +```bash +curl -sS http://127.0.0.1:8080/healthz | jq . +``` + +## 指标与 trace(C4) + +```bash +curl -sS http://127.0.0.1:8080/metrics | jq . +``` + +- `/metrics` 返回进程内聚合指标(requests/tokens/cost/熔断次数等) +- 每次分析会追加一条 JSONL 到 `AIFA_TRACE_LOG_PATH`(默认 `trace.log`) +- 单请求 token 达到 `AIFA_MAX_TOKENS_PER_REQUEST` 时会触发熔断并返回 `partial` + +## 分析(SSE) + +请求体校验失败时返回 **422**(FastAPI 默认,字段为 `detail`)。请求体过大返回 **400**。 + +```bash +curl -sS -N \ + -H "Authorization: Bearer dev-only-change-me" \ + -H "Content-Type: application/json" \ + -d '{"session_id":"550e8400-e29b-41d4-a716-446655440000","mode":"initial","case_context":{"case_name":"demo_case","batch":"b1","platform":"Android"}}' \ + http://127.0.0.1:8080/v1/analyze +``` + +无 `Authorization` 或 token 错误时返回 **401**(JSON,非 SSE)。 + +## LLM 说明 + +使用 OpenAI 兼容 `AsyncOpenAI`,默认请求 `response_format={"type":"json_object"}`。若你的兼容网关不支持该参数,可能报错;此时可仅用 Mock 跑通 CI,或联系管理员调整网关。 + +## 测试 + +```bash +cd ai-failure-analyzer +pip install -e ".[dev]" +pytest -q +``` + +## Docker(可选) + +镜像与 **dt-report 根目录 Dockerfile** 对齐:`FROM ubuntu:20.04`、`COPY docker/sources.list`,`apt` 安装 **`python3` / `python3-pip` / `python3-venv`**(focal 上为 **Python 3.8.x**),`python3 -m venv /opt/venv`,**不**使用 deadsnakes / Launchpad PPA。应用代码兼容 **3.8+**(见 `pyproject.toml`)。 + +**构建必须在仓库根目录执行**(否则无法 `COPY docker/sources.list`): + +```bash +cd /path/to/QualityBoard # 仓库根目录 +docker build -f ai-failure-analyzer/Dockerfile -t aifa:a1 . +docker run --rm -e AIFA_INTERNAL_TOKEN=test -e AIFA_LLM_MOCK=1 -p 8080:8080 aifa:a1 +``` + +内网 PyPI 与 dt-report 相同方式传入即可,例如: + +`--build-arg PIP_INDEX_URL=... --build-arg PIP_TRUSTED_HOST=...` + +若需 **Python 3.10/3.11** 独立镜像,可自行使用 `FROM python:3.11-slim` 等单独维护一条构建线;本仓库默认与 dt-report 同一 Ubuntu + apt Python 路径。 diff --git a/ai-failure-analyzer/ai_failure_analyzer/__init__.py b/ai-failure-analyzer/ai_failure_analyzer/__init__.py new file mode 100644 index 0000000..69a20fb --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/__init__.py @@ -0,0 +1 @@ +"""AIFA — ai-failure-analyzer 服务包。""" diff --git a/ai-failure-analyzer/ai_failure_analyzer/api/__init__.py b/ai-failure-analyzer/ai_failure_analyzer/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/api/__init__.py @@ -0,0 +1 @@ + diff --git a/ai-failure-analyzer/ai_failure_analyzer/api/health.py b/ai-failure-analyzer/ai_failure_analyzer/api/health.py new file mode 100644 index 0000000..b158fc1 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/api/health.py @@ -0,0 +1,33 @@ +"""健康检查。""" + +from fastapi import APIRouter, Depends + +from ai_failure_analyzer.core.config import Settings, get_settings +from ai_failure_analyzer.services.observability import get_metrics_snapshot + +router = APIRouter(tags=["health"]) + + +@router.get("/healthz") +async def healthz(settings: Settings = Depends(get_settings)) -> dict: + if settings.aifa_llm_mock: + llm_status = "ok" + elif settings.aifa_llm_base_url and settings.aifa_llm_api_key: + llm_status = "ok" + else: + llm_status = "not_configured" + + return { + "status": "ok", + "checks": { + "process": "ok", + "report_fetch": "skipped", + "codehub": "skipped", + "llm": llm_status, + }, + } + + +@router.get("/metrics") +async def metrics() -> dict: + return get_metrics_snapshot() diff --git a/ai-failure-analyzer/ai_failure_analyzer/api/v1/__init__.py b/ai-failure-analyzer/ai_failure_analyzer/api/v1/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/api/v1/__init__.py @@ -0,0 +1 @@ + diff --git a/ai-failure-analyzer/ai_failure_analyzer/api/v1/analyze.py b/ai-failure-analyzer/ai_failure_analyzer/api/v1/analyze.py new file mode 100644 index 0000000..ba7de39 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/api/v1/analyze.py @@ -0,0 +1,140 @@ +"""分析入口(SSE)。""" + +import json +import time +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from typing_extensions import Annotated +from fastapi.responses import StreamingResponse +from pydantic import ValidationError + +from ai_failure_analyzer.api.v1.schemas.request import AnalyzeRequest +from ai_failure_analyzer.core.config import Settings, get_settings +from ai_failure_analyzer.core.security import verify_bearer +from ai_failure_analyzer.services.analyze_service import stream_analyze +from ai_failure_analyzer.services.observability import ( + append_trace_line, + build_trace_payload, + record_analyze_outcome, +) + +router = APIRouter(tags=["分析"]) + +MAX_BODY_BYTES = 512 * 1024 + + +def _parse_sse_chunk(chunk: str) -> tuple: + event_name = "" + data_payload = {} + lines = chunk.splitlines() + data_raw = "" + for line in lines: + if line.startswith("event:"): + event_name = line[len("event:") :].strip() + elif line.startswith("data:"): + data_raw = line[len("data:") :].strip() + if data_raw: + try: + parsed = json.loads(data_raw) + if isinstance(parsed, dict): + data_payload = parsed + except Exception: # noqa: BLE001 + data_payload = {} + return event_name, data_payload + + +@router.post("/analyze") +async def analyze( + request: Request, + _authorized: Annotated[None, Depends(verify_bearer)], + settings: Settings = Depends(get_settings), +) -> StreamingResponse: + body = await request.body() + if len(body) > MAX_BODY_BYTES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="请求体超过允许大小", + ) + try: + payload = AnalyzeRequest.model_validate_json(body) + except ValidationError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=e.errors(), + ) from e + + request_id = request.headers.get("x-request-id") or str(uuid.uuid4()) + stream_started_at = time.perf_counter() + + async def event_stream(): + final_status = "error" + final_trace = {} + final_data_gaps = [] + error_code = "" + error_message = "" + try: + async for chunk in stream_analyze(payload, request_id, settings): + event_name, data_payload = _parse_sse_chunk(chunk) + if event_name == "report": + final_status = str(data_payload.get("status", "error")) + trace_obj = data_payload.get("trace", {}) + if isinstance(trace_obj, dict): + final_trace = trace_obj + report_obj = data_payload.get("report", {}) + if isinstance(report_obj, dict): + gaps = report_obj.get("data_gaps", []) + if isinstance(gaps, list): + final_data_gaps = [str(x) for x in gaps] + elif event_name == "error": + final_status = "error" + error_code = str(data_payload.get("error_code", "")).strip() + error_message = str(data_payload.get("message", "")).strip() + yield chunk + except Exception as exc: # noqa: BLE001 + error_message = str(exc) + raise + finally: + elapsed_ms = int((time.perf_counter() - stream_started_at) * 1000) + llm_input_tokens = int(final_trace.get("llm_input_tokens", 0) or 0) + llm_output_tokens = int(final_trace.get("llm_output_tokens", 0) or 0) + estimated_cost = float(final_trace.get("estimated_cost", 0.0) or 0.0) + token_budget_triggered = bool(final_trace.get("token_budget_triggered", False)) + external_dependency_error = False + if final_status == "partial": + joined = " ".join(final_data_gaps) + external_dependency_error = ( + "失败" in joined or "超时" in joined or "异常" in joined or "error" in joined.lower() + ) + record_analyze_outcome( + status=final_status, + elapsed_ms=elapsed_ms, + llm_input_tokens=llm_input_tokens, + llm_output_tokens=llm_output_tokens, + estimated_cost=estimated_cost, + circuit_breaker_triggered=token_budget_triggered, + external_dependency_error=external_dependency_error, + ) + trace_line = build_trace_payload( + request_id=request_id, + session_id=payload.session_id, + history_id=payload.case_context.history_id if payload.case_context else 0, + status=final_status, + elapsed_ms=elapsed_ms, + trace_obj=final_trace, + error_code=error_code, + error_message=error_message, + data_gaps=final_data_gaps, + ) + append_trace_line(settings.aifa_trace_log_path, trace_line) + + headers = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Request-ID": request_id, + } + return StreamingResponse( + event_stream(), + media_type="text/event-stream; charset=utf-8", + headers=headers, + ) diff --git a/ai-failure-analyzer/ai_failure_analyzer/api/v1/schemas/__init__.py b/ai-failure-analyzer/ai_failure_analyzer/api/v1/schemas/__init__.py new file mode 100644 index 0000000..82e55e8 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/api/v1/schemas/__init__.py @@ -0,0 +1,4 @@ +from ai_failure_analyzer.api.v1.schemas.report import AnalyzeReportEnvelope +from ai_failure_analyzer.api.v1.schemas.request import AnalyzeRequest + +__all__ = ["AnalyzeRequest", "AnalyzeReportEnvelope"] diff --git a/ai-failure-analyzer/ai_failure_analyzer/api/v1/schemas/report.py b/ai-failure-analyzer/ai_failure_analyzer/api/v1/schemas/report.py new file mode 100644 index 0000000..155a372 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/api/v1/schemas/report.py @@ -0,0 +1,63 @@ +"""SSE ``report`` 事件与 trace 结构(A1 最小集)。""" + +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field + + +FailureCategory = Literal[ + "bug", + "环境问题", + "规格变更,用例需适配", + "用例不稳定,需加固", + "unknown", +] +ReportStatus = Literal["ok", "partial", "error"] + + +class EvidenceItem(BaseModel): + id: str = "e0" + type: str = "history_pattern" + source: str = "payload" + snippet: str = "" + reference: str = "" + + +class StageTimelineItem(BaseModel): + stage: str + message: str + elapsed_ms: int = 0 + + +class ReportInner(BaseModel): + failure_category: FailureCategory = "unknown" + verdict: Optional[str] = None + confidence: float = Field(default=0.0, ge=0.0, le=1.0) + summary: str = "" + detailed_reason: str = "" + rationale_summary: Optional[str] = None + stage_timeline: List[StageTimelineItem] = Field(default_factory=list) + evidence: List[EvidenceItem] = Field(default_factory=list) + suspect_patches: Optional[list] = None + suggested_next_steps: Optional[List[str]] = None + data_gaps: List[str] = Field(default_factory=list) + + +class TracePayload(BaseModel): + skills_invoked: List[str] = Field(default_factory=lambda: ["llm_single"]) + tool_calls: int = 0 + llm_input_tokens: int = 0 + llm_output_tokens: int = 0 + estimated_cost: float = 0.0 + token_budget_triggered: bool = False + degrade_reasons: List[str] = Field(default_factory=list) + elapsed_ms: int = 0 + + +class AnalyzeReportEnvelope(BaseModel): + """``event: report`` 的 data JSON 顶层。""" + + session_id: str + status: ReportStatus + report: ReportInner + trace: TracePayload diff --git a/ai-failure-analyzer/ai_failure_analyzer/api/v1/schemas/request.py b/ai-failure-analyzer/ai_failure_analyzer/api/v1/schemas/request.py new file mode 100644 index 0000000..95d907b --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/api/v1/schemas/request.py @@ -0,0 +1,55 @@ +"""与架构 §4.1 对齐的请求体(无 log_url)。""" + +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field + + +class CaseContext(BaseModel): + model_config = {"extra": "ignore"} + + history_id: Optional[int] = None + batch: Optional[str] = None + case_name: Optional[str] = None + platform: Optional[str] = None + main_module: Optional[str] = None + module: Optional[str] = None + subtask: Optional[str] = None + start_time: Optional[str] = None + case_result: Optional[str] = None + code_branch: Optional[str] = None + screenshot_index_url: Optional[str] = None + screenshot_urls: Optional[List[str]] = None + pipeline_url: Optional[str] = None + reports_url: Optional[str] = None + case_level: Optional[str] = None + last_success_batch: Optional[str] = None + success_screenshot_index_url: Optional[str] = None + success_screenshot_urls: Optional[List[str]] = None + + +class RecentExecution(BaseModel): + model_config = {"extra": "ignore"} + + start_time: Optional[str] = None + case_result: Optional[str] = None + code_branch: Optional[str] = None + + +class RepoHint(BaseModel): + model_config = {"extra": "ignore"} + + repo_url: Optional[str] = None + default_branch: Optional[str] = None + path_hints: Optional[List[str]] = None + + +class AnalyzeRequest(BaseModel): + session_id: str = Field(..., description="会话 UUID") + mode: Literal["initial", "follow_up"] = "initial" + follow_up_message: Optional[str] = None + case_context: Optional[CaseContext] = None + recent_executions: Optional[List[RecentExecution]] = None + repo_hint: Optional[RepoHint] = None + + model_config = {"extra": "forbid"} diff --git a/ai-failure-analyzer/ai_failure_analyzer/core/__init__.py b/ai-failure-analyzer/ai_failure_analyzer/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/core/__init__.py @@ -0,0 +1 @@ + diff --git a/ai-failure-analyzer/ai_failure_analyzer/core/config.py b/ai-failure-analyzer/ai_failure_analyzer/core/config.py new file mode 100644 index 0000000..89aa22f --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/core/config.py @@ -0,0 +1,134 @@ +"""环境变量与全局配置。""" + +from typing import List, Optional, Union + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +def _parse_bool_mock(v: Union[str, bool, None]) -> bool: + if v is None or v is False: + return False + if v is True: + return True + s = str(v).strip().lower() + return s in ("1", "true", "yes", "on") + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + aifa_internal_token: str = Field(default="", validation_alias="AIFA_INTERNAL_TOKEN") + aifa_llm_base_url: Optional[str] = Field(default=None, validation_alias="AIFA_LLM_BASE_URL") + aifa_llm_api_key: Optional[str] = Field(default=None, validation_alias="AIFA_LLM_API_KEY") + aifa_llm_model: str = Field(default="gpt-4o-mini", validation_alias="AIFA_LLM_MODEL") + aifa_llm_mock: bool = Field(default=False, validation_alias="AIFA_LLM_MOCK") + aifa_port: int = Field(default=8080, validation_alias="AIFA_PORT") + aifa_fetch_connect_timeout_seconds: float = Field( + default=3.0, + validation_alias="AIFA_FETCH_CONNECT_TIMEOUT_SECONDS", + ) + aifa_fetch_read_timeout_seconds: float = Field( + default=10.0, + validation_alias="AIFA_FETCH_READ_TIMEOUT_SECONDS", + ) + aifa_report_max_chars: int = Field(default=20000, validation_alias="AIFA_REPORT_MAX_CHARS") + aifa_screenshot_max_bytes: int = Field( + default=2_000_000, + validation_alias="AIFA_SCREENSHOT_MAX_BYTES", + ) + aifa_screenshot_max_images: int = Field( + default=10, + validation_alias="AIFA_SCREENSHOT_MAX_IMAGES", + ) + aifa_fetch_url_max_length: int = Field( + default=2048, + validation_alias="AIFA_FETCH_URL_MAX_LENGTH", + ) + aifa_fetch_allowed_hosts: List[str] = Field( + default_factory=list, + validation_alias="AIFA_FETCH_ALLOWED_HOSTS", + ) + aifa_codehub_base_url: Optional[str] = Field( + default=None, + validation_alias="AIFA_CODEHUB_BASE_URL", + ) + aifa_codehub_token: Optional[str] = Field( + default=None, + validation_alias="AIFA_CODEHUB_TOKEN", + ) + aifa_codehub_connect_timeout_seconds: float = Field( + default=3.0, + validation_alias="AIFA_CODEHUB_CONNECT_TIMEOUT_SECONDS", + ) + aifa_codehub_read_timeout_seconds: float = Field( + default=15.0, + validation_alias="AIFA_CODEHUB_READ_TIMEOUT_SECONDS", + ) + aifa_codehub_list_limit: int = Field( + default=30, + validation_alias="AIFA_CODEHUB_LIST_LIMIT", + ) + aifa_codehub_diff_max_lines: int = Field( + default=500, + validation_alias="AIFA_CODEHUB_DIFF_MAX_LINES", + ) + aifa_codehub_diff_top_n: int = Field( + default=5, + validation_alias="AIFA_CODEHUB_DIFF_TOP_N", + ) + aifa_codehub_fallback_window_days: int = Field( + default=7, + validation_alias="AIFA_CODEHUB_FALLBACK_WINDOW_DAYS", + ) + aifa_max_tokens_per_request: int = Field( + default=80000, + validation_alias="AIFA_MAX_TOKENS_PER_REQUEST", + ) + aifa_price_per_1k_input: float = Field( + default=0.0, + validation_alias="AIFA_PRICE_PER_1K_INPUT", + ) + aifa_price_per_1k_output: float = Field( + default=0.0, + validation_alias="AIFA_PRICE_PER_1K_OUTPUT", + ) + aifa_trace_log_path: str = Field( + default="trace.log", + validation_alias="AIFA_TRACE_LOG_PATH", + ) + aifa_max_concurrent_analyses: int = Field( + default=8, + validation_alias="AIFA_MAX_CONCURRENT_ANALYSES", + ) + + @field_validator("aifa_llm_mock", mode="before") + @classmethod + def _validate_mock(cls, v: object) -> bool: + return _parse_bool_mock(v) # type: ignore[arg-type] + + @field_validator("aifa_fetch_allowed_hosts", mode="before") + @classmethod + def _validate_allowed_hosts(cls, v: object) -> List[str]: + if v is None: + return [] + if isinstance(v, list): + out: List[str] = [] + for item in v: + text = str(item).strip().lower() + if text: + out.append(text) + return out + text = str(v).strip() + if not text: + return [] + return [host.strip().lower() for host in text.split(",") if host.strip()] + + +def get_settings() -> Settings: + """每次调用重新读取环境(便于测试与热更新配置)。""" + return Settings() diff --git a/ai-failure-analyzer/ai_failure_analyzer/core/security.py b/ai-failure-analyzer/ai_failure_analyzer/core/security.py new file mode 100644 index 0000000..0d747ae --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/core/security.py @@ -0,0 +1,33 @@ +"""内部 Service Token 校验。""" + +import secrets +from typing import Optional + +from fastapi import Depends, Header, HTTPException, status +from typing_extensions import Annotated + +from ai_failure_analyzer.core.config import Settings, get_settings + + +def verify_bearer( + authorization: Annotated[Optional[str], Header(include_in_schema=False)] = None, + settings: Settings = Depends(get_settings), +) -> None: + """校验 ``Authorization: Bearer ``。""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未授权:缺少或非法的 Authorization", + ) + token = authorization[len("Bearer ") :].strip() + expected = settings.aifa_internal_token + if not expected: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未授权:服务未配置内部令牌", + ) + if not secrets.compare_digest(token, expected): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未授权:令牌无效", + ) diff --git a/ai-failure-analyzer/ai_failure_analyzer/main.py b/ai-failure-analyzer/ai_failure_analyzer/main.py new file mode 100644 index 0000000..6ebbc28 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/main.py @@ -0,0 +1,22 @@ +"""FastAPI 应用入口。""" + +import logging + +from fastapi import FastAPI + +from ai_failure_analyzer.api.health import router as health_router +from ai_failure_analyzer.api.v1.analyze import router as analyze_v1_router + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", +) + +app = FastAPI( + title="ai-failure-analyzer", + description="AIFA — AI 辅助失败原因分析(Phase A1)", + version="0.1.0", +) + +app.include_router(health_router) +app.include_router(analyze_v1_router, prefix="/v1") diff --git a/ai-failure-analyzer/ai_failure_analyzer/services/__init__.py b/ai-failure-analyzer/ai_failure_analyzer/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/services/__init__.py @@ -0,0 +1 @@ + diff --git a/ai-failure-analyzer/ai_failure_analyzer/services/analyze_service.py b/ai-failure-analyzer/ai_failure_analyzer/services/analyze_service.py new file mode 100644 index 0000000..1c965ea --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/services/analyze_service.py @@ -0,0 +1,1133 @@ +"""B2 三阶段分析:Plan / Act / Synthesize、session 复用、SSE 事件生成。""" + +from __future__ import annotations + +import json +import logging +import time +from datetime import datetime, timedelta +from typing import Any, AsyncIterator, Dict, List, Optional, Tuple + +from openai import APIError, AsyncOpenAI, AuthenticationError +from pydantic import BaseModel, ValidationError + +from ai_failure_analyzer.api.v1.schemas.report import ( + AnalyzeReportEnvelope, + EvidenceItem, + ReportInner, + StageTimelineItem, + TracePayload, +) +from ai_failure_analyzer.api.v1.schemas.request import AnalyzeRequest +from ai_failure_analyzer.core.config import Settings +from ai_failure_analyzer.services.evidence_tools import ( + build_success_urls_by_batch_replace, + CodeHubAuthError, + codehub_get_commit_diff, + codehub_list_commits, + fetch_report_html, + fetch_screenshot_b64, + resolve_evidence_urls, +) +from ai_failure_analyzer.services.sse import format_sse + +logger = logging.getLogger(__name__) + +MAX_PROMPT_JSON_CHARS = 120_000 +MAX_PLAN_JSON_CHARS = 8_000 +MAX_SYNTHESIS_INPUT_CHARS = 30_000 +SESSION_TTL_SECONDS = 30 * 60 +SESSION_MAX_SIZE = 500 +CATEGORY_BUG = "bug" +CATEGORY_ENV = "环境问题" +CATEGORY_SPEC_CHANGE = "规格变更,用例需适配" +CATEGORY_FLAKY = "用例不稳定,需加固" +CATEGORY_UNKNOWN = "unknown" +ALLOWED_FAILURE_CATEGORIES = ( + CATEGORY_BUG, + CATEGORY_ENV, + CATEGORY_SPEC_CHANGE, + CATEGORY_FLAKY, + CATEGORY_UNKNOWN, +) +SKILL_HISTORY = "history_skill" +SKILL_REPORT = "report_analysis_skill" +SKILL_SCREENSHOT = "screenshot_skill" +SKILL_CODE_BLAME = "code_blame_skill" +SKILL_SYNTHESIS = "synthesis_skill" +ALLOWED_SKILLS = (SKILL_HISTORY, SKILL_REPORT, SKILL_SCREENSHOT, SKILL_CODE_BLAME) + +_SESSION_STORE: Dict[str, Dict[str, Any]] = {} + +SYSTEM_PROMPT = """你是测试失败归因助手。只根据用户给出的 JSON 上下文做推断,不要编造未出现的堆栈或日志。 +必须只输出一个 JSON 对象(不要 markdown),字段严格如下: +{ + "failure_category": "bug|环境问题|规格变更,用例需适配|用例不稳定,需加固|unknown", + "summary": "一句话结论(中文)", + "detailed_reason": "详细失败原因(中文)", + "confidence": 0.0 到 1.0 的小数, + "data_gaps": ["可选,列举信息不足点,中文"] +} +failure_category 含义:bug=产品缺陷;环境问题=环境异常;规格变更,用例需适配=需求或界面已变化;用例不稳定,需加固=偶发抖动或竞态;unknown=证据不足。 +若缺少成功侧截图对比证据,请不要输出「规格变更,用例需适配」或「用例不稳定,需加固」,应使用 unknown 并在 data_gaps 说明。""" + +PLAN_SYSTEM_PROMPT = """你是失败分析任务规划器。你必须只输出一个 JSON 对象,格式如下: +{ + "skills": ["history_skill", "report_analysis_skill", "screenshot_skill", "code_blame_skill"], + "reason": "简短中文说明" +} +规则: +1) skills 只能从 history_skill/report_analysis_skill/screenshot_skill/code_blame_skill 中选择; +2) skills 最少 1 个,最多 4 个; +3) 不要输出 markdown,不要输出额外字段。""" + + +class PlanPayload(BaseModel): + skills: List[str] + reason: Optional[str] = None + + +class SessionPayload(BaseModel): + session_id: str + mode: str + plan: List[str] + skill_summaries: Dict[str, Dict[str, object]] + stage_timeline: List[StageTimelineItem] + data_gaps: List[str] + updated_at: int + +def apply_category_guard(inner: ReportInner, evidence_sufficiency: str) -> None: + """成功侧对比证据不足时禁止规格变更/用例不稳定(B4 硬规则)。""" + if evidence_sufficiency == "enough": + return + if inner.failure_category in (CATEGORY_SPEC_CHANGE, CATEGORY_FLAKY): + msg = "成功侧截图对比证据不足,已将失败归类降级为 unknown" + inner.failure_category = CATEGORY_UNKNOWN + if msg not in inner.data_gaps: + inner.data_gaps = list(inner.data_gaps) + [msg] + + +def _truncate_payload_for_prompt(payload: AnalyzeRequest) -> str: + raw = payload.model_dump_json(exclude_none=True) + if len(raw) > MAX_PROMPT_JSON_CHARS: + raw = raw[:MAX_PROMPT_JSON_CHARS] + "\n…(已截断)" + return raw + + +def _now_ts() -> int: + return int(time.time()) + + +def _cleanup_sessions() -> None: + now_ts = _now_ts() + expired_ids: List[str] = [] + for sid, item in _SESSION_STORE.items(): + updated_at = int(item.get("updated_at", 0)) + if now_ts - updated_at > SESSION_TTL_SECONDS: + expired_ids.append(sid) + for sid in expired_ids: + _SESSION_STORE.pop(sid, None) + if len(_SESSION_STORE) <= SESSION_MAX_SIZE: + return + survivors: List[Tuple[str, int]] = [] + for sid, item in _SESSION_STORE.items(): + survivors.append((sid, int(item.get("updated_at", 0)))) + survivors.sort(key=lambda x: x[1], reverse=True) + keep = set(sid for sid, _ in survivors[:SESSION_MAX_SIZE]) + stale = [sid for sid in _SESSION_STORE.keys() if sid not in keep] + for sid in stale: + _SESSION_STORE.pop(sid, None) + + +def _save_session( + session_id: str, + mode: str, + plan: List[str], + skill_summaries: Dict[str, Dict[str, object]], + stage_timeline: List[StageTimelineItem], + data_gaps: List[str], +) -> None: + _cleanup_sessions() + payload = SessionPayload( + session_id=session_id, + mode=mode, + plan=plan, + skill_summaries=skill_summaries, + stage_timeline=stage_timeline, + data_gaps=data_gaps, + updated_at=_now_ts(), + ) + _SESSION_STORE[session_id] = payload.model_dump(mode="json") + + +def _load_session(session_id: str) -> Optional[SessionPayload]: + _cleanup_sessions() + raw = _SESSION_STORE.get(session_id) + if raw is None: + return None + try: + payload = SessionPayload.model_validate(raw) + except ValidationError: + _SESSION_STORE.pop(session_id, None) + return None + if _now_ts() - payload.updated_at > SESSION_TTL_SECONDS: + _SESSION_STORE.pop(session_id, None) + return None + return payload + + +def _inner_from_llm_dict(data: Dict[str, object]) -> ReportInner: + raw_cat = str(data.get("failure_category", CATEGORY_UNKNOWN)).strip() + category_alias = { + "bug": CATEGORY_BUG, + "env": CATEGORY_ENV, + "环境问题": CATEGORY_ENV, + "spec_change": CATEGORY_SPEC_CHANGE, + "规格变更": CATEGORY_SPEC_CHANGE, + "规格变更,用例需适配": CATEGORY_SPEC_CHANGE, + "flaky": CATEGORY_FLAKY, + "用例不稳定": CATEGORY_FLAKY, + "用例不稳定,需加固": CATEGORY_FLAKY, + "unknown": CATEGORY_UNKNOWN, + } + cat = category_alias.get(raw_cat.lower(), category_alias.get(raw_cat, CATEGORY_UNKNOWN)) + if cat not in ALLOWED_FAILURE_CATEGORIES: + cat = CATEGORY_UNKNOWN + conf_raw = data.get("confidence", 0.0) + try: + conf = float(conf_raw) # type: ignore[arg-type] + except (TypeError, ValueError): + conf = 0.0 + conf = max(0.0, min(1.0, conf)) + gaps = data.get("data_gaps") + gap_list: List[str] = [] + if isinstance(gaps, list): + gap_list = [str(x) for x in gaps] + elif gaps is not None: + gap_list = [str(gaps)] + summary = str(data.get("summary", "")).strip()[:2000] + if not summary: + summary = "暂未形成明确结论" + detailed_reason = str(data.get("detailed_reason", "")).strip()[:20000] + if not detailed_reason: + detailed_reason = "当前证据不足,建议结合日志、截图和历史执行进一步人工复核。" + return ReportInner( + failure_category=cat, # type: ignore[arg-type] + summary=summary, + detailed_reason=detailed_reason, + confidence=conf, + data_gaps=gap_list, + evidence=[], + stage_timeline=[], + ) + + +def _safe_summary_limit(text: str, limit: int) -> str: + if limit <= 0: + return "" + if len(text) <= limit: + return text + return text[:limit] + "\n…(已截断)" + + +def _total_tokens(trace: TracePayload) -> int: + return int(trace.llm_input_tokens) + int(trace.llm_output_tokens) + + +def _calculate_estimated_cost(trace: TracePayload, settings: Settings) -> float: + in_cost = (max(0, int(trace.llm_input_tokens)) / 1000.0) * float(settings.aifa_price_per_1k_input) + out_cost = (max(0, int(trace.llm_output_tokens)) / 1000.0) * float(settings.aifa_price_per_1k_output) + return round(in_cost + out_cost, 6) + + +def _mark_token_budget_triggered( + trace: TracePayload, + settings: Settings, + data_gaps: List[str], +) -> bool: + max_tokens = max(1, int(settings.aifa_max_tokens_per_request)) + if _total_tokens(trace) < max_tokens: + return False + trace.token_budget_triggered = True + reason = "单请求 token 超过上限,已触发熔断并降级为 partial" + if reason not in trace.degrade_reasons: + trace.degrade_reasons.append(reason) + if reason not in data_gaps: + data_gaps.append(reason) + return True + + +def _build_history_summary(payload: AnalyzeRequest) -> Dict[str, object]: + recent = payload.recent_executions or [] + total = len(recent) + fail_count = 0 + pass_count = 0 + last_pass_batch = payload.case_context.last_success_batch if payload.case_context else None + for item in recent: + result = (item.case_result or "").strip().lower() + if result in ("pass", "success", "通过"): + pass_count += 1 + elif result: + fail_count += 1 + pattern = "new" + if total == 0: + pattern = "unknown" + elif fail_count == 0 and pass_count > 0: + pattern = "stable" + elif pass_count > 0 and fail_count > 0: + pattern = "flaky" + elif fail_count > 0 and pass_count == 0: + pattern = "regression" + return { + "pattern": pattern, + "last_pass_batch": last_pass_batch, + "recent_total": total, + "recent_pass": pass_count, + "recent_fail": fail_count, + } + + +def _derive_plan_from_payload(payload: AnalyzeRequest) -> List[str]: + plan: List[str] = [SKILL_HISTORY] + ctx = payload.case_context + if ctx is not None and (ctx.reports_url or "").strip(): + plan.append(SKILL_REPORT) + has_any_screenshot = False + if ctx is not None: + if len(ctx.screenshot_urls or []) > 0: + has_any_screenshot = True + elif (ctx.screenshot_index_url or "").strip(): + has_any_screenshot = True + if has_any_screenshot: + plan.append(SKILL_SCREENSHOT) + if payload.repo_hint is not None and (payload.repo_hint.repo_url or "").strip(): + plan.append(SKILL_CODE_BLAME) + dedup: List[str] = [] + for skill in plan: + if skill in ALLOWED_SKILLS and skill not in dedup: + dedup.append(skill) + if not dedup: + dedup = [SKILL_HISTORY] + return dedup + + +def _normalize_plan(raw_skills: List[str]) -> List[str]: + result: List[str] = [] + for item in raw_skills: + skill = str(item).strip() + if skill in ALLOWED_SKILLS and skill not in result: + result.append(skill) + if not result: + return [SKILL_HISTORY] + return result + + +async def _run_plan_stage( + payload: AnalyzeRequest, + settings: Settings, + trace: TracePayload, +) -> Tuple[List[str], List[str]]: + data_gaps: List[str] = [] + derived = _derive_plan_from_payload(payload) + if settings.aifa_llm_mock: + return derived, data_gaps + if not settings.aifa_llm_base_url or not settings.aifa_llm_api_key: + data_gaps.append("LLM 未配置,Plan 阶段使用兜底策略") + return derived, data_gaps + client = AsyncOpenAI( + api_key=settings.aifa_llm_api_key, + base_url=settings.aifa_llm_base_url, + timeout=60.0, + ) + plan_input = { + "mode": payload.mode, + "case_context": payload.case_context.model_dump(exclude_none=True) if payload.case_context else {}, + "recent_executions_count": len(payload.recent_executions or []), + "repo_hint": payload.repo_hint.model_dump(exclude_none=True) if payload.repo_hint else {}, + } + user_content = _safe_summary_limit( + json.dumps(plan_input, ensure_ascii=False), + MAX_PLAN_JSON_CHARS, + ) + try: + completion = await client.chat.completions.create( + model=settings.aifa_llm_model, + messages=[ + {"role": "system", "content": PLAN_SYSTEM_PROMPT}, + {"role": "user", "content": user_content}, + ], + response_format={"type": "json_object"}, + temperature=0, + ) + msg = completion.choices[0].message.content or "{}" + parsed = json.loads(msg) + if not isinstance(parsed, dict): + raise ValueError("plan_not_object") + plan_payload = PlanPayload.model_validate(parsed) + plan = _normalize_plan(plan_payload.skills) + usage = completion.usage + if usage: + trace.llm_input_tokens += usage.prompt_tokens or 0 + trace.llm_output_tokens += usage.completion_tokens or 0 + return plan, data_gaps + except Exception as exc: # noqa: BLE001 + logger.warning( + "Plan 阶段失败,使用兜底计划 request_id=%s session_id=%s error=%s", + "-", # request_id 仅用于日志弱依赖,避免修改函数签名复杂度 + payload.session_id, + type(exc).__name__, + ) + data_gaps.append("Plan 输出无效,已使用兜底计划") + return derived, data_gaps + + +def _extract_report_skill_summary(report_result: Dict[str, object]) -> Dict[str, object]: + if "error" in report_result: + return { + "error_lines": [], + "stack_summary": "", + "keywords": [], + "report_excerpt": "", + "error": report_result.get("error"), + } + text = str(report_result.get("text", "")) + lines = [line.strip() for line in text.splitlines() if line.strip()] + error_lines = [line for line in lines if "error" in line.lower()][:5] + keywords: List[str] = [] + for token in ("exception", "error", "timeout", "assert"): + if token in text.lower(): + keywords.append(token) + return { + "error_lines": error_lines, + "stack_summary": lines[0] if lines else "", + "keywords": keywords, + "report_excerpt": _safe_summary_limit(text, 800), + } + + +def _extract_screenshot_skill_summary(result: Dict[str, object]) -> Dict[str, object]: + if "error" in result: + return { + "ui_state": "unknown", + "visible_error_text": "", + "description": "", + "compare_notes": [], + "error": result.get("error"), + } + if "images" in result: + count = int(result.get("image_count", 0) or 0) + notes: List[str] = [] + if bool(result.get("truncated_by_max_images")): + notes.append("截图数量超限,已截断采样") + skipped = result.get("skipped_errors") + if isinstance(skipped, list) and skipped: + notes.append("部分截图拉取失败") + return { + "ui_state": "captured", + "visible_error_text": "", + "description": "共获取截图 %s 张" % count, + "compare_notes": notes, + } + return { + "ui_state": "captured", + "visible_error_text": "", + "description": "获取到单张截图", + "compare_notes": [], + } + + +def _extract_image_entries(result: Dict[str, object]) -> List[Dict[str, object]]: + if "error" in result: + return [] + if "images" in result and isinstance(result.get("images"), list): + entries: List[Dict[str, object]] = [] + for item in result.get("images", []): + if isinstance(item, dict): + entries.append(item) + return entries + if "base64" in result: + return [result] + return [] + + +def _build_compare_summary( + failed_images: List[Dict[str, object]], + success_images: List[Dict[str, object]], +) -> Dict[str, object]: + failed_count = len(failed_images) + success_count = len(success_images) + paired_count = min(failed_count, success_count) + unpaired_failed = max(0, failed_count - paired_count) + unpaired_success = max(0, success_count - paired_count) + + if success_count == 0: + sufficiency = "missing" + elif paired_count == 0: + sufficiency = "insufficient" + else: + sufficiency = "enough" + + diff_count = 0 + same_count = 0 + for idx in range(paired_count): + left = str(failed_images[idx].get("content_sha256_prefix", "")) + right = str(success_images[idx].get("content_sha256_prefix", "")) + if left and right and left == right: + same_count += 1 + else: + diff_count += 1 + + compare_notes: List[str] = [ + "配对 %s 组,差异 %s 组,一致 %s 组" % (paired_count, diff_count, same_count) + ] + if unpaired_failed > 0: + compare_notes.append("失败侧存在未配对截图 %s 张" % unpaired_failed) + if unpaired_success > 0: + compare_notes.append("成功侧存在未配对截图 %s 张" % unpaired_success) + if sufficiency != "enough": + compare_notes.append("成功侧对比证据%s" % ("缺失" if sufficiency == "missing" else "不足")) + + return { + "compare_summary": "失败侧 %s 张,对比成功侧 %s 张" % (failed_count, success_count), + "compare_notes": compare_notes, + "failed_image_count": failed_count, + "success_image_count": success_count, + "paired_count": paired_count, + "unpaired_failed_count": unpaired_failed, + "unpaired_success_count": unpaired_success, + "evidence_sufficiency": sufficiency, + } + + +def _build_synthesis_input( + payload: AnalyzeRequest, + plan: List[str], + skill_summaries: Dict[str, Dict[str, object]], + data_gaps: List[str], + follow_up_message: Optional[str], +) -> str: + base = { + "mode": payload.mode, + "session_id": payload.session_id, + "follow_up_message": follow_up_message, + "plan": plan, + "case_context": payload.case_context.model_dump(exclude_none=True) if payload.case_context else {}, + "skill_summaries": skill_summaries, + "data_gaps": data_gaps, + } + raw = json.dumps(base, ensure_ascii=False) + return _safe_summary_limit(raw, MAX_SYNTHESIS_INPUT_CHARS) + + +def _parse_batch_like_time(value: Optional[str]) -> Optional[datetime]: + text = (value or "").strip() + if not text: + return None + formats = ( + "%Y%m%d_%H%M%S", + "%Y%m%d%H%M%S", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + ) + for fmt in formats: + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + return None + + +def _compute_codehub_time_window(payload: AnalyzeRequest, settings: Settings) -> Dict[str, object]: + ctx = payload.case_context + raw_until = "" + raw_success = "" + if ctx is not None: + raw_until = (ctx.start_time or ctx.batch or "").strip() + raw_success = (ctx.last_success_batch or "").strip() + if not raw_until: + raw_until = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S") + + until_dt = _parse_batch_like_time(raw_until) + success_dt = _parse_batch_like_time(raw_success) + fallback_days = max(1, int(settings.aifa_codehub_fallback_window_days)) + used_fallback = False + since_dt: Optional[datetime] = None + if success_dt is not None and until_dt is not None and success_dt <= until_dt: + since_dt = success_dt + else: + used_fallback = True + if until_dt is not None: + since_dt = until_dt - timedelta(days=fallback_days) + + since = raw_success + until = raw_until + if until_dt is not None: + until = until_dt.strftime("%Y-%m-%dT%H:%M:%S") + if since_dt is not None: + since = since_dt.strftime("%Y-%m-%dT%H:%M:%S") + if not since: + if until_dt is not None: + since = (until_dt - timedelta(days=fallback_days)).strftime("%Y-%m-%dT%H:%M:%S") + used_fallback = True + else: + since = raw_until + + if since and until and since > until: + used_fallback = True + if until_dt is not None: + since = (until_dt - timedelta(days=fallback_days)).strftime("%Y-%m-%dT%H:%M:%S") + until = until_dt.strftime("%Y-%m-%dT%H:%M:%S") + + return { + "since": since, + "until": until, + "used_fallback_window": used_fallback, + } + + +def _extract_code_blame_summary( + commits: List[Dict[str, object]], + diff_map: Dict[str, Dict[str, object]], + window_meta: Dict[str, object], + skipped_reason: List[str], +) -> Dict[str, object]: + suspect_patches: List[Dict[str, object]] = [] + for item in commits: + sha = str(item.get("sha", "")).strip() + if not sha: + continue + diff_item = diff_map.get(sha, {}) + files_touched: List[str] = [] + raw_files = item.get("files") + if isinstance(raw_files, list): + for f in raw_files: + text = str(f).strip() + if text: + files_touched.append(text) + changed = diff_item.get("files_changed") + if isinstance(changed, list): + for f in changed: + text = str(f).strip() + if text and text not in files_touched: + files_touched.append(text) + why = "位于成功到失败时间窗内" + if files_touched: + why = why + ",且命中相关路径" + suspect_patches.append( + { + "sha": sha, + "author": str(item.get("author", "")).strip(), + "commit_time": str(item.get("time", "")).strip(), + "summary": str(item.get("message", "")).strip(), + "why_suspect": why, + "files_touched": files_touched, + "diff_excerpt": str(diff_item.get("diff", "")).strip(), + "truncated": bool(diff_item.get("truncated", False)), + } + ) + return { + "suspect_patches": suspect_patches, + "codehub_meta": { + "time_window": { + "since": str(window_meta.get("since", "")), + "until": str(window_meta.get("until", "")), + "used_fallback_window": bool(window_meta.get("used_fallback_window", False)), + }, + "list_count": len(commits), + "diff_fetched_count": len(diff_map), + "skipped_reason": skipped_reason, + }, + } + + +def _build_mock_inner_from_summaries( + has_evidence: bool, + data_gaps: List[str], +) -> ReportInner: + category = CATEGORY_SPEC_CHANGE if has_evidence else CATEGORY_UNKNOWN + summary = "Mock:已按三阶段流程生成结论" + detailed = "该结果由 Mock 模式生成,用于验证 Plan/Act/Synthesize 主循环。" + return ReportInner( + failure_category=category, # type: ignore[arg-type] + summary=summary, + detailed_reason=detailed, + confidence=0.5, + data_gaps=data_gaps, + evidence=[], + stage_timeline=[], + ) + + +async def stream_analyze( + payload: AnalyzeRequest, + request_id: str, + settings: Settings, +) -> AsyncIterator[str]: + """产生 SSE 文本块(含 progress / report 或 error)。""" + t0 = time.perf_counter() + timeline: List[StageTimelineItem] = [] + stage_start = time.perf_counter() + trace = TracePayload(skills_invoked=[]) + all_data_gaps: List[str] = [] + screenshot_evidence_sufficiency = "missing" + + plan: List[str] = [] + skill_summaries: Dict[str, Dict[str, object]] = {} + if payload.mode == "follow_up": + yield format_sse("progress", {"stage": "synthesize_started", "message": "正在加载历史会话并生成追问结论..."}) + session = _load_session(payload.session_id) + if session is None: + yield format_sse( + "error", + { + "error_code": "session_not_found", + "message": "未找到可复用会话,请先发起 initial 分析", + }, + ) + return + plan = list(session.plan) + skill_summaries = dict(session.skill_summaries) + all_data_gaps = list(session.data_gaps) + timeline = list(session.stage_timeline) + trace.skills_invoked = [SKILL_SYNTHESIS] + else: + yield format_sse("progress", {"stage": "plan_started", "message": "正在规划分析路径..."}) + plan, plan_gaps = await _run_plan_stage(payload, settings, trace) + all_data_gaps.extend(plan_gaps) + timeline.append( + StageTimelineItem( + stage="plan", + message="规划技能执行顺序", + elapsed_ms=int((time.perf_counter() - stage_start) * 1000), + ) + ) + yield format_sse("progress", {"stage": "plan_done", "message": "规划完成"}) + + yield format_sse("progress", {"stage": "act_started", "message": "正在执行证据提取与分析..."}) + act_start = time.perf_counter() + resolved_urls: Dict[str, object] = {} + if payload.case_context is not None: + resolved_urls = await resolve_evidence_urls( + settings=settings, + reports_url=payload.case_context.reports_url, + screenshot_urls=payload.case_context.screenshot_urls, + screenshot_index_url=payload.case_context.screenshot_index_url, + ) + resolution_errors = resolved_urls.get("errors") + if isinstance(resolution_errors, list): + for item in resolution_errors: + if not isinstance(item, dict): + continue + code = str(item.get("code", "unknown")) + field = str(item.get("field", "unknown")) + label = "截图" if "screenshot" in field else "报告" + gap = "%s URL 解析失败(%s:%s)" % (label, field, code) + if gap not in all_data_gaps: + all_data_gaps.append(gap) + resolution_meta = resolved_urls.get("url_resolution_meta") + if isinstance(resolution_meta, dict): + warning_items = resolution_meta.get("warnings") + if isinstance(warning_items, list): + for warning in warning_items: + text = "URL 解析提示:%s" % str(warning) + if text not in all_data_gaps: + all_data_gaps.append(text) + for skill in plan: + trace.skills_invoked.append(skill) + if skill == SKILL_HISTORY: + skill_summaries[skill] = _build_history_summary(payload) + continue + if skill == SKILL_REPORT: + report_url = str(resolved_urls.get("report_url", "")).strip() + if not report_url: + all_data_gaps.append("缺少 reports_url,跳过报告分析") + skill_summaries[skill] = { + "error_lines": [], + "stack_summary": "", + "keywords": [], + "report_excerpt": "", + "error": "reports_url_missing", + } + continue + report_result = await fetch_report_html(report_url, settings=settings) + trace.tool_calls += 1 + if "error" in report_result: + all_data_gaps.append("报告抓取失败:%s" % str(report_result.get("error"))) + skill_summaries[skill] = _extract_report_skill_summary(report_result) + continue + if skill == SKILL_SCREENSHOT: + screenshot_candidates_raw = resolved_urls.get("screenshot_urls", []) + screenshot_candidates: List[str] = [] + if isinstance(screenshot_candidates_raw, list): + for item in screenshot_candidates_raw: + if isinstance(item, str) and item.strip(): + screenshot_candidates.append(item.strip()) + if not screenshot_candidates: + all_data_gaps.append("缺少截图 URL,跳过截图分析") + skill_summaries[skill] = { + "ui_state": "unknown", + "visible_error_text": "", + "description": "", + "compare_notes": [], + "evidence_sufficiency": "missing", + "error": "screenshot_url_missing", + } + continue + + success_resolved = await resolve_evidence_urls( + settings=settings, + reports_url=None, + screenshot_urls=( + payload.case_context.success_screenshot_urls if payload.case_context else None + ), + screenshot_index_url=( + payload.case_context.success_screenshot_index_url if payload.case_context else None + ), + ) + success_candidates_raw = success_resolved.get("screenshot_urls", []) + success_candidates: List[str] = [] + if isinstance(success_candidates_raw, list): + for item in success_candidates_raw: + if isinstance(item, str) and item.strip(): + success_candidates.append(item.strip()) + if not success_candidates and payload.case_context is not None: + replaced = build_success_urls_by_batch_replace( + settings=settings, + failed_urls=screenshot_candidates, + failed_batch=payload.case_context.batch, + success_batch=payload.case_context.last_success_batch, + ) + replaced_urls = replaced.get("success_urls", []) + if isinstance(replaced_urls, list): + for item in replaced_urls: + if isinstance(item, str) and item.strip(): + success_candidates.append(item.strip()) + replaced_errors = replaced.get("errors", []) + if isinstance(replaced_errors, list): + for item in replaced_errors: + if not isinstance(item, dict): + continue + gap = "成功侧 URL 生成失败:%s" % str(item.get("code", "unknown")) + if gap not in all_data_gaps: + all_data_gaps.append(gap) + + screenshot_result = await fetch_screenshot_b64(screenshot_candidates[0], settings=settings) + trace.tool_calls += 1 + if "error" in screenshot_result: + all_data_gaps.append("截图抓取失败:%s" % str(screenshot_result.get("error"))) + screenshot_summary = _extract_screenshot_skill_summary(screenshot_result) + failed_images = _extract_image_entries(screenshot_result) + success_result: Dict[str, object] = {"error": "success_screenshot_missing"} + success_images: List[Dict[str, object]] = [] + if success_candidates: + success_result = await fetch_screenshot_b64(success_candidates[0], settings=settings) + trace.tool_calls += 1 + if "error" in success_result: + all_data_gaps.append("成功侧截图抓取失败:%s" % str(success_result.get("error"))) + success_images = _extract_image_entries(success_result) + compare_block = _build_compare_summary(failed_images, success_images) + prior_notes = screenshot_summary.get("compare_notes") + prior_list: List[str] = [] + if isinstance(prior_notes, list): + prior_list = [str(x) for x in prior_notes] + compare_notes = compare_block.pop("compare_notes", []) + screenshot_summary.update(compare_block) + screenshot_summary["compare_notes"] = prior_list + list(compare_notes) + screenshot_evidence_sufficiency = str(compare_block.get("evidence_sufficiency", "missing")) + if screenshot_evidence_sufficiency != "enough": + hint = ( + "成功侧截图对比证据缺失" + if screenshot_evidence_sufficiency == "missing" + else "成功侧截图对比证据不足" + ) + if hint not in all_data_gaps: + all_data_gaps.append(hint) + meta = resolved_urls.get("url_resolution_meta") + if isinstance(meta, dict): + screenshot_summary["url_resolution_meta"] = meta + success_meta = success_resolved.get("url_resolution_meta") + if isinstance(success_meta, dict): + screenshot_summary["success_url_resolution_meta"] = success_meta + skill_summaries[skill] = screenshot_summary + continue + if skill == SKILL_CODE_BLAME: + repo_url = "" + branch = "master" + path_hints: List[str] = [] + if payload.repo_hint is not None: + repo_url = (payload.repo_hint.repo_url or "").strip() + branch = (payload.repo_hint.default_branch or "").strip() or "master" + for item in payload.repo_hint.path_hints or []: + text = str(item).strip() + if text: + path_hints.append(text) + if not repo_url: + all_data_gaps.append("仓库映射缺失,跳过代码归因") + skill_summaries[skill] = {"suspect_patches": [], "error": "repo_hint_missing"} + continue + + window_meta = _compute_codehub_time_window(payload, settings) + if bool(window_meta.get("used_fallback_window", False)): + all_data_gaps.append( + "last_success_batch 缺失或非法,代码归因已回退 %s 天时间窗" + % int(settings.aifa_codehub_fallback_window_days) + ) + + try: + list_result = await codehub_list_commits( + settings=settings, + repo_url=repo_url, + branch=branch, + since=str(window_meta.get("since", "")), + until=str(window_meta.get("until", "")), + path_filters=path_hints, + limit=settings.aifa_codehub_list_limit, + ) + except CodeHubAuthError: + yield format_sse( + "error", + { + "error_code": "codehub_auth_invalid", + "message": "CodeHub token 无效,请联系管理员检查服务配置", + }, + ) + return + trace.tool_calls += 1 + if "error" in list_result: + all_data_gaps.append("CodeHub 提交查询失败:%s" % str(list_result.get("error"))) + skill_summaries[skill] = { + "suspect_patches": [], + "codehub_meta": { + "time_window": { + "since": str(window_meta.get("since", "")), + "until": str(window_meta.get("until", "")), + "used_fallback_window": bool(window_meta.get("used_fallback_window", False)), + }, + "list_count": 0, + "diff_fetched_count": 0, + "skipped_reason": ["list_commits_failed"], + }, + "error": list_result.get("error"), + } + continue + commits_raw = list_result.get("commits") + commits: List[Dict[str, object]] = [] + if isinstance(commits_raw, list): + for item in commits_raw: + if isinstance(item, dict): + commits.append(item) + if not commits: + skill_summaries[skill] = { + "suspect_patches": [], + "codehub_meta": { + "time_window": { + "since": str(window_meta.get("since", "")), + "until": str(window_meta.get("until", "")), + "used_fallback_window": bool(window_meta.get("used_fallback_window", False)), + }, + "list_count": 0, + "diff_fetched_count": 0, + "skipped_reason": [], + }, + } + all_data_gaps.append("该时间窗无新增提交") + continue + top_n = max(1, min(5, settings.aifa_codehub_diff_top_n)) + candidates = commits[:top_n] + diff_map: Dict[str, Dict[str, object]] = {} + skipped_reason: List[str] = [] + for commit_item in candidates: + sha = str(commit_item.get("sha", "")).strip() + if not sha: + skipped_reason.append("missing_sha") + continue + try: + diff_result = await codehub_get_commit_diff( + settings=settings, + repo_url=repo_url, + sha=sha, + max_lines=settings.aifa_codehub_diff_max_lines, + ) + except CodeHubAuthError: + yield format_sse( + "error", + { + "error_code": "codehub_auth_invalid", + "message": "CodeHub token 无效,请联系管理员检查服务配置", + }, + ) + return + trace.tool_calls += 1 + if "error" in diff_result: + skipped_reason.append("diff_failed:%s" % sha) + continue + diff_map[sha] = diff_result + skill_summaries[skill] = _extract_code_blame_summary( + commits=candidates, + diff_map=diff_map, + window_meta=window_meta, + skipped_reason=skipped_reason, + ) + continue + all_data_gaps.append("未知 skill:%s" % skill) + timeline.append( + StageTimelineItem( + stage="act", + message="执行技能分析", + elapsed_ms=int((time.perf_counter() - act_start) * 1000), + ) + ) + yield format_sse("progress", {"stage": "act_done", "message": "执行完成"}) + + ss_summary = skill_summaries.get(SKILL_SCREENSHOT) + if isinstance(ss_summary, dict): + suff = ss_summary.get("evidence_sufficiency") + if isinstance(suff, str) and suff in ("enough", "insufficient", "missing"): + screenshot_evidence_sufficiency = suff + + yield format_sse("progress", {"stage": "synthesize_started", "message": "正在生成归因结论..."}) + synth_start = time.perf_counter() + inner: ReportInner + token_budget_exceeded = _mark_token_budget_triggered(trace, settings, all_data_gaps) + + if token_budget_exceeded: + inner = ReportInner( + failure_category=CATEGORY_UNKNOWN, # type: ignore[arg-type] + summary="已触发 token 成本保护,返回降级结论", + detailed_reason="当前请求的 token 消耗达到上限,系统已停止后续高成本模型调用并返回部分结果。请缩小分析范围后重试。", + confidence=0.2, + data_gaps=list(all_data_gaps), + evidence=[], + stage_timeline=[], + ) + elif settings.aifa_llm_mock: + inner = _build_mock_inner_from_summaries( + screenshot_evidence_sufficiency == "enough", + list(all_data_gaps), + ) + elif not settings.aifa_llm_base_url or not settings.aifa_llm_api_key: + yield format_sse( + "error", + { + "error_code": "llm_not_configured", + "message": "未配置 LLM:请设置 AIFA_LLM_BASE_URL 与 AIFA_LLM_API_KEY,或启用 AIFA_LLM_MOCK", + }, + ) + return + else: + client = AsyncOpenAI( + api_key=settings.aifa_llm_api_key, + base_url=settings.aifa_llm_base_url, + timeout=120.0, + ) + user_content = _build_synthesis_input( + payload=payload, + plan=plan, + skill_summaries=skill_summaries, + data_gaps=all_data_gaps, + follow_up_message=payload.follow_up_message, + ) + try: + completion = await client.chat.completions.create( + model=settings.aifa_llm_model, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_content}, + ], + response_format={"type": "json_object"}, + temperature=0.2, + ) + msg = completion.choices[0].message.content or "{}" + data = json.loads(msg) + if not isinstance(data, dict): + raise ValueError("LLM 返回非 JSON 对象") + inner = _inner_from_llm_dict(data) + usage = completion.usage + if usage: + trace.llm_input_tokens += usage.prompt_tokens or 0 + trace.llm_output_tokens += usage.completion_tokens or 0 + _mark_token_budget_triggered(trace, settings, all_data_gaps) + except (AuthenticationError, APIError, json.JSONDecodeError, ValueError, IndexError) as e: + logger.warning( + "LLM 调用失败 request_id=%s session_id=%s: %s", + request_id, + payload.session_id, + type(e).__name__, + ) + yield format_sse( + "error", + { + "error_code": "llm_error", + "message": "模型调用或解析失败:%s" % type(e).__name__, + }, + ) + return + + apply_category_guard(inner, screenshot_evidence_sufficiency) + if all_data_gaps: + combined = list(inner.data_gaps) + for gap in all_data_gaps: + if gap not in combined: + combined.append(gap) + inner.data_gaps = combined + + evidence: List[EvidenceItem] = [] + for skill_name in trace.skills_invoked[:6]: + summary = skill_summaries.get(skill_name, {}) + snippet = _safe_summary_limit(json.dumps(summary, ensure_ascii=False), 300) + evidence.append( + EvidenceItem( + id="e_%s" % skill_name.replace("_skill", ""), + type=skill_name, + source="skill_summary", + snippet=snippet, + reference=skill_name, + ) + ) + inner.evidence = evidence + timeline.append( + StageTimelineItem( + stage="synthesis", + message="生成归因结论", + elapsed_ms=int((time.perf_counter() - synth_start) * 1000), + ) + ) + inner.stage_timeline = timeline + trace.skills_invoked = trace.skills_invoked or [SKILL_SYNTHESIS] + + elapsed_ms = int((time.perf_counter() - t0) * 1000) + trace.elapsed_ms = elapsed_ms + trace.estimated_cost = _calculate_estimated_cost(trace, settings) + status = "ok" + if len(inner.data_gaps) > 0: + status = "partial" + if payload.mode == "initial": + _save_session( + session_id=payload.session_id, + mode=payload.mode, + plan=plan, + skill_summaries=skill_summaries, + stage_timeline=timeline, + data_gaps=inner.data_gaps, + ) + yield format_sse("progress", {"stage": "synthesize_done", "message": "结论生成完成"}) + + envelope = AnalyzeReportEnvelope( + session_id=payload.session_id, + status=status, # type: ignore[arg-type] + report=inner, + trace=trace, + ) + + logger.info( + "分析完成 request_id=%s session_id=%s elapsed_ms=%s in_tokens=%s out_tokens=%s category=%s status=%s", + request_id, + payload.session_id, + elapsed_ms, + trace.llm_input_tokens, + trace.llm_output_tokens, + inner.failure_category, + status, + ) + + yield format_sse("report", envelope.model_dump(mode="json")) diff --git a/ai-failure-analyzer/ai_failure_analyzer/services/evidence_tools.py b/ai-failure-analyzer/ai_failure_analyzer/services/evidence_tools.py new file mode 100644 index 0000000..39717e8 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/services/evidence_tools.py @@ -0,0 +1,769 @@ +"""B1 工具:报告与截图证据拉取。""" + +from __future__ import annotations + +import hashlib +import logging +import re +import time +from typing import Dict, List, Optional, Sequence +from urllib.parse import urljoin, urlparse + +import httpx +try: + from selectolax.parser import HTMLParser +except ImportError: # pragma: no cover - fallback for minimal environments + HTMLParser = None # type: ignore[assignment] + +from ai_failure_analyzer.core.config import Settings + +logger = logging.getLogger(__name__) + +_IMG_EXT_RE = re.compile(r"\.(png|jpe?g|webp|gif|bmp)$", re.IGNORECASE) +_TAG_RE = re.compile(r"<[^>]+>") +_SPACE_RE = re.compile(r"[ \t]+") + + +class CodeHubAuthError(RuntimeError): + """CodeHub 鉴权失败(token 无效).""" + + +def _extract_attr_values(html_text: str, tag: str, attr: str) -> List[str]: + pattern = re.compile( + r"<%s\b[^>]*\b%s\s*=\s*['\"]([^'\"]+)['\"]" % (re.escape(tag), re.escape(attr)), + re.IGNORECASE, + ) + values: List[str] = [] + for match in pattern.finditer(html_text): + value = (match.group(1) or "").strip() + if value: + values.append(value) + return values + + +def _truncate_text(text: str, max_chars: int) -> Dict[str, object]: + if max_chars <= 0: + return {"text": "", "truncated": bool(text), "content_length": len(text)} + if len(text) <= max_chars: + return {"text": text, "truncated": False, "content_length": len(text)} + return {"text": text[:max_chars], "truncated": True, "content_length": len(text)} + + +def _is_allowed_url(url: str, settings: Settings) -> Optional[str]: + if len(url) > settings.aifa_fetch_url_max_length: + return "url_too_long" + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return "url_scheme_not_allowed" + host = (parsed.hostname or "").lower() + if not host: + return "url_host_missing" + allowed = settings.aifa_fetch_allowed_hosts + if allowed: + allowed_hit = False + for candidate in allowed: + c = candidate.strip().lower() + if not c: + continue + if host == c or host.endswith("." + c): + allowed_hit = True + break + if not allowed_hit: + return "url_host_not_allowed" + return None + + +def _build_timeout(settings: Settings) -> httpx.Timeout: + return httpx.Timeout( + connect=settings.aifa_fetch_connect_timeout_seconds, + read=settings.aifa_fetch_read_timeout_seconds, + write=settings.aifa_fetch_read_timeout_seconds, + pool=settings.aifa_fetch_connect_timeout_seconds, + ) + + +def _build_codehub_timeout(settings: Settings) -> httpx.Timeout: + return httpx.Timeout( + connect=settings.aifa_codehub_connect_timeout_seconds, + read=settings.aifa_codehub_read_timeout_seconds, + write=settings.aifa_codehub_read_timeout_seconds, + pool=settings.aifa_codehub_connect_timeout_seconds, + ) + + +def _extract_image_urls_from_html(base_url: str, html_text: str) -> List[str]: + if HTMLParser is None: + urls = [_normalize_candidate_url(item, base_url) for item in _extract_attr_values(html_text, "img", "src")] + href_candidates = _extract_attr_values(html_text, "a", "href") + for item in href_candidates: + if _IMG_EXT_RE.search(item): + urls.append(_normalize_candidate_url(item, base_url)) + return _dedup_keep_order([item for item in urls if item]) + + parser = HTMLParser(html_text) + urls: List[str] = [] + + for node in parser.css("img"): + src = (node.attributes.get("src") or "").strip() + if src: + urls.append(urljoin(base_url, src)) + + for node in parser.css("a"): + href = (node.attributes.get("href") or "").strip() + if not href: + continue + if _IMG_EXT_RE.search(href): + urls.append(urljoin(base_url, href)) + + return _dedup_keep_order(urls) + + +def _pick_urls_by_limit(urls: Sequence[str], max_images: int) -> List[str]: + if max_images <= 0: + return [] + if len(urls) <= max_images: + return list(urls) + if max_images == 1: + return [urls[-1]] + return list(urls[: max_images - 1]) + [urls[-1]] + + +def _safe_url_for_log(url: str) -> str: + parsed = urlparse(url) + path = parsed.path or "" + if len(path) > 64: + path = path[:61] + "..." + return "%s://%s%s" % (parsed.scheme or "?", parsed.netloc or "?", path) + + +def _extract_text_from_html(html_text: str) -> str: + if HTMLParser is not None: + parser = HTMLParser(html_text) + if parser.body: + return parser.body.text(separator="\n", strip=True) + return parser.text(separator="\n") + + no_tags = _TAG_RE.sub(" ", html_text) + normalized = _SPACE_RE.sub(" ", no_tags) + lines = [item.strip() for item in normalized.splitlines() if item.strip()] + return "\n".join(lines) + + +def _normalize_candidate_url(url: str, base_url: Optional[str] = None) -> Optional[str]: + raw = (url or "").strip() + if not raw: + return None + if base_url: + raw = urljoin(base_url, raw) + parsed = urlparse(raw) + if parsed.scheme not in ("http", "https"): + return None + if not parsed.netloc: + return None + return parsed._replace(fragment="").geturl() + + +def _looks_like_image_url(url: str) -> bool: + parsed = urlparse(url) + path = parsed.path or "" + return bool(_IMG_EXT_RE.search(path)) + + +def _dedup_keep_order(urls: Sequence[str]) -> List[str]: + dedup: List[str] = [] + seen = set() + for item in urls: + if item in seen: + continue + seen.add(item) + dedup.append(item) + return dedup + + +def _replace_batch_segment(url: str, failed_batch: str, success_batch: str) -> Optional[str]: + """B4:仅在路径段中替换批次子串,保持 scheme/host 不变。""" + if not failed_batch or not success_batch or failed_batch == success_batch: + return None + parsed = urlparse(url) + path = parsed.path or "" + segments = path.split("/") + changed = False + new_segments: List[str] = [] + for seg in segments: + if failed_batch in seg: + new_segments.append(seg.replace(failed_batch, success_batch)) + changed = True + else: + new_segments.append(seg) + if not changed: + return None + new_path = "/".join(new_segments) + return parsed._replace(path=new_path, fragment="").geturl() + + +def build_success_urls_by_batch_replace( + settings: Settings, + failed_urls: Sequence[str], + failed_batch: Optional[str], + success_batch: Optional[str], + max_screenshot_candidates: Optional[int] = None, +) -> Dict[str, object]: + """B4:按 batch 替换规则从失败侧 URL 生成成功侧截图候选。""" + limit = ( + max_screenshot_candidates + if max_screenshot_candidates is not None + else settings.aifa_screenshot_max_images + ) + src_failed_batch = (failed_batch or "").strip() + dst_success_batch = (success_batch or "").strip() + if not src_failed_batch or not dst_success_batch: + return { + "success_urls": [], + "meta": {"source": "batch_replace", "truncated": False}, + "errors": [{"code": "batch_replace_not_applicable", "field": "batch", "message": "批次信息缺失"}], + } + + generated: List[str] = [] + for raw in failed_urls: + normalized = _normalize_candidate_url(str(raw)) + if not normalized: + continue + replaced = _replace_batch_segment(normalized, src_failed_batch, dst_success_batch) + if not replaced: + continue + err = _is_allowed_url(replaced, settings) + if err: + continue + generated.append(replaced) + generated = _dedup_keep_order(generated) + selected = _pick_urls_by_limit(generated, limit) + + errors: List[Dict[str, str]] = [] + if not selected: + errors.append( + { + "code": "batch_replace_not_applicable", + "field": "success_screenshot_urls", + "message": "未找到可替换的批次段或替换结果不可用", + } + ) + return { + "success_urls": selected, + "meta": { + "source": "batch_replace", + "input_count": len(failed_urls), + "output_count": len(selected), + "truncated": len(generated) > len(selected), + }, + "errors": errors, + } + + +def _is_codehub_repo_allowed(repo_url: str, settings: Settings) -> bool: + base = (settings.aifa_codehub_base_url or "").strip() + if not base: + return False + repo_host = (urlparse(repo_url).hostname or "").strip().lower() + base_host = (urlparse(base).hostname or "").strip().lower() + if not repo_host or not base_host: + return False + return repo_host == base_host + + +def _repo_path_from_url(repo_url: str) -> Optional[str]: + parsed = urlparse(repo_url) + path = (parsed.path or "").strip("/") + if not path: + return None + if path.endswith(".git"): + path = path[:-4] + return path or None + + +def _truncate_diff_by_lines(diff_text: str, max_lines: int) -> Dict[str, object]: + lines = diff_text.splitlines() + if max_lines <= 0: + return {"diff": "", "truncated": len(lines) > 0, "line_count": len(lines)} + if len(lines) <= max_lines: + return {"diff": diff_text, "truncated": False, "line_count": len(lines)} + selected = lines[:max_lines] + return { + "diff": "\n".join(selected), + "truncated": True, + "line_count": len(lines), + } + + +def _normalize_commit_item(raw: Dict[str, object]) -> Dict[str, object]: + author = raw.get("author") + author_name = "" + if isinstance(author, dict): + author_name = str(author.get("name", "")).strip() + if not author_name: + author_name = str(raw.get("author_name", "")).strip() + if not author_name: + author_name = str(raw.get("committer_name", "")).strip() + files: List[str] = [] + raw_files = raw.get("files") + if isinstance(raw_files, list): + for item in raw_files: + if isinstance(item, str): + text = item.strip() + if text: + files.append(text) + elif isinstance(item, dict): + name = str(item.get("path", "") or item.get("file", "")).strip() + if name: + files.append(name) + sha = str(raw.get("sha", "") or raw.get("id", "")).strip() + commit_time = str( + raw.get("time", "") + or raw.get("committed_at", "") + or raw.get("created_at", "") + or raw.get("timestamp", "") + ).strip() + message = str(raw.get("message", "") or raw.get("title", "")).strip() + return { + "sha": sha, + "author": author_name, + "time": commit_time, + "message": message, + "files": files, + } + + +async def codehub_list_commits( + settings: Settings, + repo_url: str, + branch: str, + since: str, + until: str, + path_filters: Optional[Sequence[str]] = None, + limit: int = 30, +) -> Dict[str, object]: + """B5: 调用 CodeHub 提交列表接口,输出标准 commits 列表。""" + base_url = (settings.aifa_codehub_base_url or "").strip().rstrip("/") + token = (settings.aifa_codehub_token or "").strip() + if not base_url or not token: + return {"error": "codehub_not_configured", "detail": "missing_base_url_or_token"} + if not _is_codehub_repo_allowed(repo_url, settings): + return {"error": "codehub_repo_not_allowed", "detail": _safe_url_for_log(repo_url)} + repo_path = _repo_path_from_url(repo_url) + if not repo_path: + return {"error": "invalid_repo_url", "detail": _safe_url_for_log(repo_url)} + + timeout = _build_codehub_timeout(settings) + endpoint = "%s/api/v1/repos/%s/commits" % (base_url, repo_path) + safe_limit = max(1, min(100, limit)) + params = { + "branch": branch, + "since": since, + "until": until, + "limit": safe_limit, + } + filters: List[str] = [] + for item in path_filters or []: + text = str(item).strip() + if text: + filters.append(text) + if filters: + params["path_filters"] = ",".join(filters) + headers = {"Authorization": "Bearer %s" % token} + + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: + try: + resp = await client.get(endpoint, params=params, headers=headers) + except httpx.HTTPError as exc: + return {"error": "http_error", "detail": str(exc)} + if resp.status_code == 401: + raise CodeHubAuthError("codehub_unauthorized") + if resp.status_code >= 400: + return {"error": "http_status_error", "detail": "status=%s" % resp.status_code} + try: + payload = resp.json() + except ValueError: + return {"error": "invalid_json", "detail": "list_commits_response_not_json"} + + raw_commits: List[Dict[str, object]] = [] + if isinstance(payload, dict): + c = payload.get("commits") + if isinstance(c, list): + raw_commits = [item for item in c if isinstance(item, dict)] + elif isinstance(payload, list): + raw_commits = [item for item in payload if isinstance(item, dict)] + + commits = [_normalize_commit_item(item) for item in raw_commits] + commits = [item for item in commits if str(item.get("sha", "")).strip()] + return {"commits": commits} + + +async def codehub_get_commit_diff( + settings: Settings, + repo_url: str, + sha: str, + max_lines: int = 500, +) -> Dict[str, object]: + """B5: 调用 CodeHub commit diff 接口,并做行级截断。""" + base_url = (settings.aifa_codehub_base_url or "").strip().rstrip("/") + token = (settings.aifa_codehub_token or "").strip() + if not base_url or not token: + return {"error": "codehub_not_configured", "detail": "missing_base_url_or_token"} + if not _is_codehub_repo_allowed(repo_url, settings): + return {"error": "codehub_repo_not_allowed", "detail": _safe_url_for_log(repo_url)} + repo_path = _repo_path_from_url(repo_url) + commit_sha = (sha or "").strip() + if not repo_path or not commit_sha: + return {"error": "invalid_arguments", "detail": "repo_or_sha_missing"} + + timeout = _build_codehub_timeout(settings) + endpoint = "%s/api/v1/repos/%s/commits/%s/diff" % (base_url, repo_path, commit_sha) + headers = {"Authorization": "Bearer %s" % token} + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: + try: + resp = await client.get(endpoint, headers=headers) + except httpx.HTTPError as exc: + return {"error": "http_error", "detail": str(exc)} + if resp.status_code == 401: + raise CodeHubAuthError("codehub_unauthorized") + if resp.status_code >= 400: + return {"error": "http_status_error", "detail": "status=%s" % resp.status_code} + try: + payload = resp.json() + except ValueError: + return {"error": "invalid_json", "detail": "get_commit_diff_response_not_json"} + + diff_text = "" + files_changed: List[str] = [] + if isinstance(payload, dict): + raw_diff = payload.get("diff") + if isinstance(raw_diff, str): + diff_text = raw_diff + raw_files = payload.get("files_changed") + if isinstance(raw_files, list): + for item in raw_files: + if isinstance(item, str) and item.strip(): + files_changed.append(item.strip()) + elif isinstance(payload, list): + chunks: List[str] = [] + for item in payload: + if not isinstance(item, dict): + continue + part = item.get("diff") + if isinstance(part, str) and part.strip(): + chunks.append(part.strip()) + filename = str(item.get("new_path", "") or item.get("old_path", "")).strip() + if filename: + files_changed.append(filename) + diff_text = "\n".join(chunks) + + if not diff_text and isinstance(payload, dict): + alt = payload.get("patch") + if isinstance(alt, str): + diff_text = alt + + trunc = _truncate_diff_by_lines(diff_text, max_lines) + return { + "diff": trunc["diff"], + "truncated": trunc["truncated"], + "line_count": trunc["line_count"], + "files_changed": _dedup_keep_order(files_changed), + } + + +async def resolve_evidence_urls( + settings: Settings, + reports_url: Optional[str], + screenshot_urls: Optional[Sequence[str]], + screenshot_index_url: Optional[str], + max_screenshot_candidates: Optional[int] = None, +) -> Dict[str, object]: + """B3:解析并归一化报告/截图 URL,输出稳定候选集合。""" + limit = ( + max_screenshot_candidates + if max_screenshot_candidates is not None + else settings.aifa_screenshot_max_images + ) + errors: List[Dict[str, str]] = [] + warnings: List[str] = [] + source = "none" + + normalized_report_url: Optional[str] = None + if (reports_url or "").strip(): + candidate = _normalize_candidate_url(str(reports_url)) + if not candidate: + errors.append( + {"code": "invalid_reports_url", "field": "reports_url", "message": "reports_url 非法"} + ) + else: + report_err = _is_allowed_url(candidate, settings) + if report_err: + errors.append( + { + "code": report_err, + "field": "reports_url", + "message": "reports_url 不满足白名单或安全规则", + } + ) + else: + normalized_report_url = candidate + + normalized_screenshot_urls: List[str] = [] + prefilled = screenshot_urls or [] + prefilled_candidates: List[str] = [] + prefilled_rejected = 0 + for item in prefilled: + normalized = _normalize_candidate_url(str(item)) + if not normalized: + prefilled_rejected += 1 + continue + if _is_allowed_url(normalized, settings): + prefilled_rejected += 1 + continue + prefilled_candidates.append(normalized) + prefilled_candidates = _dedup_keep_order(prefilled_candidates) + + if prefilled_candidates: + source = "prefilled_urls" + normalized_screenshot_urls = prefilled_candidates + elif (screenshot_index_url or "").strip(): + source = "index_page" + index_url = _normalize_candidate_url(str(screenshot_index_url)) + if not index_url: + errors.append( + { + "code": "invalid_screenshot_index_url", + "field": "screenshot_index_url", + "message": "screenshot_index_url 非法", + } + ) + else: + index_err = _is_allowed_url(index_url, settings) + if index_err: + errors.append( + { + "code": index_err, + "field": "screenshot_index_url", + "message": "screenshot_index_url 不满足白名单或安全规则", + } + ) + else: + timeout = _build_timeout(settings) + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: + try: + response = await client.get(index_url) + except httpx.HTTPError as exc: + errors.append( + { + "code": "screenshot_index_http_error", + "field": "screenshot_index_url", + "message": str(exc), + } + ) + else: + if response.status_code >= 400: + errors.append( + { + "code": "screenshot_index_http_status_error", + "field": "screenshot_index_url", + "message": "status=%s" % response.status_code, + } + ) + else: + content_type = (response.headers.get("content-type") or "").lower() + if "html" not in content_type: + errors.append( + { + "code": "screenshot_index_unsupported_content_type", + "field": "screenshot_index_url", + "message": content_type or "unknown", + } + ) + else: + extracted = _extract_image_urls_from_html(index_url, response.text) + valid_candidates: List[str] = [] + for item in extracted: + normalized = _normalize_candidate_url(item) + if not normalized: + continue + if not _looks_like_image_url(normalized): + continue + if _is_allowed_url(normalized, settings): + continue + valid_candidates.append(normalized) + normalized_screenshot_urls = _dedup_keep_order(valid_candidates) + + if prefilled_rejected > 0: + warnings.append("prefilled_screenshot_urls_rejected=%s" % prefilled_rejected) + + selected = _pick_urls_by_limit(normalized_screenshot_urls, limit) + if len(normalized_screenshot_urls) > len(selected): + warnings.append("screenshot_candidates_truncated") + + if source == "none" and not selected: + errors.append( + { + "code": "missing_screenshot_urls", + "field": "screenshot_urls", + "message": "缺少可用截图 URL", + } + ) + + return { + "report_url": normalized_report_url, + "screenshot_urls": selected, + "url_resolution_meta": { + "source": source, + "input_count": len(prefilled), + "output_count": len(selected), + "truncated": len(normalized_screenshot_urls) > len(selected), + "warnings": warnings, + }, + "errors": errors, + } + + +async def _fetch_binary_image( + client: httpx.AsyncClient, + screenshot_url: str, + max_bytes: int, +) -> Dict[str, object]: + try: + response = await client.get(screenshot_url) + except httpx.HTTPError as exc: + return {"error": "http_error", "detail": str(exc)} + if response.status_code >= 400: + return {"error": "http_status_error", "detail": "status=%s" % response.status_code} + content_type = (response.headers.get("content-type") or "").lower() + if not content_type.startswith("image/"): + return {"error": "unsupported_content_type", "detail": content_type or "unknown"} + content = response.content + if len(content) > max_bytes: + return { + "error": "image_too_large", + "detail": "size=%s exceeds max_bytes=%s" % (len(content), max_bytes), + } + import base64 + + digest = hashlib.sha256(content).hexdigest()[:16] + return { + "base64": base64.b64encode(content).decode("ascii"), + "mime": content_type.split(";")[0].strip(), + "size_bytes": len(content), + "content_sha256_prefix": digest, + "source_url": screenshot_url, + } + + +async def fetch_report_html( + reports_url: str, + settings: Settings, + max_chars: Optional[int] = None, +) -> Dict[str, object]: + """拉取报告 HTML 并提取可用文本。""" + err = _is_allowed_url(reports_url, settings) + if err: + return {"error": err, "detail": _safe_url_for_log(reports_url)} + + limit = max_chars if max_chars is not None else settings.aifa_report_max_chars + timeout = _build_timeout(settings) + t0 = time.perf_counter() + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: + try: + response = await client.get(reports_url) + except httpx.HTTPError as exc: + return {"error": "http_error", "detail": str(exc)} + elapsed_ms = int((time.perf_counter() - t0) * 1000) + if response.status_code >= 400: + return {"error": "http_status_error", "detail": "status=%s" % response.status_code} + content_type = (response.headers.get("content-type") or "").lower() + if "html" not in content_type: + return {"error": "unsupported_content_type", "detail": content_type or "unknown"} + + raw = response.text + body_text = _extract_text_from_html(raw) + result = _truncate_text(body_text, limit) + logger.info( + "B1 fetch_report_html ok url=%s elapsed_ms=%s raw_len=%s out_len=%s truncated=%s", + _safe_url_for_log(reports_url), + elapsed_ms, + len(raw), + len(str(result["text"])), + result["truncated"], + ) + return result + + +async def fetch_screenshot_b64( + screenshot_url: str, + settings: Settings, + max_bytes: Optional[int] = None, + max_images: Optional[int] = None, +) -> Dict[str, object]: + """拉取截图:支持 image 直链或 HTML 索引页。""" + err = _is_allowed_url(screenshot_url, settings) + if err: + return {"error": err, "detail": _safe_url_for_log(screenshot_url)} + + limit_bytes = max_bytes if max_bytes is not None else settings.aifa_screenshot_max_bytes + limit_images = max_images if max_images is not None else settings.aifa_screenshot_max_images + timeout = _build_timeout(settings) + t0 = time.perf_counter() + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: + try: + response = await client.get(screenshot_url) + except httpx.HTTPError as exc: + return {"error": "http_error", "detail": str(exc)} + if response.status_code >= 400: + return {"error": "http_status_error", "detail": "status=%s" % response.status_code} + + content_type = (response.headers.get("content-type") or "").lower() + if content_type.startswith("image/"): + one = await _fetch_binary_image(client, screenshot_url, limit_bytes) + if "error" in one: + return one + elapsed_ms = int((time.perf_counter() - t0) * 1000) + logger.info( + "B1 fetch_screenshot_b64 image ok url=%s elapsed_ms=%s size_bytes=%s", + _safe_url_for_log(screenshot_url), + elapsed_ms, + one.get("size_bytes"), + ) + return one + + if "html" not in content_type: + return {"error": "unsupported_content_type", "detail": content_type or "unknown"} + + urls = _extract_image_urls_from_html(screenshot_url, response.text) + picked = _pick_urls_by_limit(urls, limit_images) + images: List[Dict[str, object]] = [] + skipped_errors: List[str] = [] + for child_url in picked: + child_err = _is_allowed_url(child_url, settings) + if child_err: + skipped_errors.append("%s:%s" % (child_err, _safe_url_for_log(child_url))) + continue + one = await _fetch_binary_image(client, child_url, limit_bytes) + if "error" in one: + skipped_errors.append("%s:%s" % (one.get("error"), _safe_url_for_log(child_url))) + continue + images.append(one) + + elapsed_ms = int((time.perf_counter() - t0) * 1000) + logger.info( + "B1 fetch_screenshot_b64 index ok url=%s elapsed_ms=%s total_found=%s selected=%s ok=%s skipped=%s", + _safe_url_for_log(screenshot_url), + elapsed_ms, + len(urls), + len(picked), + len(images), + len(skipped_errors), + ) + return { + "images": images, + "image_count": len(images), + "selected_count": len(picked), + "total_found": len(urls), + "truncated_by_max_images": len(urls) > len(picked), + "skipped_errors": skipped_errors, + } + diff --git a/ai-failure-analyzer/ai_failure_analyzer/services/observability.py b/ai-failure-analyzer/ai_failure_analyzer/services/observability.py new file mode 100644 index 0000000..94ca973 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/services/observability.py @@ -0,0 +1,147 @@ +"""C4 观测与成本:trace 持久化与进程内 metrics 聚合。""" + +from __future__ import annotations + +import json +import logging +import os +import threading +from datetime import datetime +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + +_METRICS_LOCK = threading.Lock() +_REQUESTS_TOTAL = 0 +_REQUESTS_OK = 0 +_REQUESTS_PARTIAL = 0 +_REQUESTS_ERROR = 0 +_TOKENS_INPUT_TOTAL = 0 +_TOKENS_OUTPUT_TOTAL = 0 +_ESTIMATED_COST_TOTAL = 0.0 +_CIRCUIT_BREAKER_TRIGGERED_TOTAL = 0 +_EXTERNAL_DEPENDENCY_ERROR_TOTAL = 0 +_LATENCIES_MS: List[int] = [] +_MAX_LATENCY_SAMPLES = 2000 + + +def _percentile(sorted_values: List[int], q: float) -> int: + if not sorted_values: + return 0 + if q <= 0: + return sorted_values[0] + if q >= 1: + return sorted_values[-1] + idx = int((len(sorted_values) - 1) * q) + return sorted_values[idx] + + +def record_analyze_outcome( + *, + status: str, + elapsed_ms: int, + llm_input_tokens: int, + llm_output_tokens: int, + estimated_cost: float, + circuit_breaker_triggered: bool, + external_dependency_error: bool, +) -> None: + global _REQUESTS_TOTAL + global _REQUESTS_OK + global _REQUESTS_PARTIAL + global _REQUESTS_ERROR + global _TOKENS_INPUT_TOTAL + global _TOKENS_OUTPUT_TOTAL + global _ESTIMATED_COST_TOTAL + global _CIRCUIT_BREAKER_TRIGGERED_TOTAL + global _EXTERNAL_DEPENDENCY_ERROR_TOTAL + + with _METRICS_LOCK: + _REQUESTS_TOTAL += 1 + normalized = (status or "error").strip().lower() + if normalized == "ok": + _REQUESTS_OK += 1 + elif normalized == "partial": + _REQUESTS_PARTIAL += 1 + else: + _REQUESTS_ERROR += 1 + _TOKENS_INPUT_TOTAL += max(0, int(llm_input_tokens)) + _TOKENS_OUTPUT_TOTAL += max(0, int(llm_output_tokens)) + _ESTIMATED_COST_TOTAL += max(0.0, float(estimated_cost)) + if circuit_breaker_triggered: + _CIRCUIT_BREAKER_TRIGGERED_TOTAL += 1 + if external_dependency_error: + _EXTERNAL_DEPENDENCY_ERROR_TOTAL += 1 + _LATENCIES_MS.append(max(0, int(elapsed_ms))) + if len(_LATENCIES_MS) > _MAX_LATENCY_SAMPLES: + overflow = len(_LATENCIES_MS) - _MAX_LATENCY_SAMPLES + if overflow > 0: + del _LATENCIES_MS[:overflow] + + +def get_metrics_snapshot() -> Dict[str, Any]: + with _METRICS_LOCK: + ordered = sorted(_LATENCIES_MS) + p50 = _percentile(ordered, 0.50) + p95 = _percentile(ordered, 0.95) + tokens_total = _TOKENS_INPUT_TOTAL + _TOKENS_OUTPUT_TOTAL + return { + "requests_total": _REQUESTS_TOTAL, + "requests_ok": _REQUESTS_OK, + "requests_partial": _REQUESTS_PARTIAL, + "requests_error": _REQUESTS_ERROR, + "request_latency_p50_ms": p50, + "request_latency_p95_ms": p95, + "tokens_input_total": _TOKENS_INPUT_TOTAL, + "tokens_output_total": _TOKENS_OUTPUT_TOTAL, + "tokens_total": tokens_total, + "estimated_cost_total": round(_ESTIMATED_COST_TOTAL, 6), + "circuit_breaker_triggered_total": _CIRCUIT_BREAKER_TRIGGERED_TOTAL, + "external_dependency_error_total": _EXTERNAL_DEPENDENCY_ERROR_TOTAL, + } + + +def append_trace_line(trace_log_path: str, payload: Dict[str, Any]) -> None: + safe_path = (trace_log_path or "trace.log").strip() or "trace.log" + line = json.dumps(payload, ensure_ascii=False) + try: + parent = os.path.dirname(safe_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(safe_path, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: # noqa: BLE001 + logger.exception("写入 trace 失败 path=%s", safe_path) + + +def build_trace_payload( + *, + request_id: str, + session_id: str, + history_id: int, + status: str, + elapsed_ms: int, + trace_obj: Dict[str, Any], + error_code: str = "", + error_message: str = "", + data_gaps: List[str] = None, +) -> Dict[str, Any]: + ts = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + return { + "timestamp": ts, + "request_id": request_id, + "session_id": session_id, + "history_id": history_id, + "status": status, + "elapsed_ms": int(elapsed_ms), + "error_code": error_code, + "error_message": error_message, + "skills_invoked": trace_obj.get("skills_invoked", []), + "tool_calls": trace_obj.get("tool_calls", 0), + "llm_input_tokens": trace_obj.get("llm_input_tokens", 0), + "llm_output_tokens": trace_obj.get("llm_output_tokens", 0), + "estimated_cost": trace_obj.get("estimated_cost", 0.0), + "token_budget_triggered": bool(trace_obj.get("token_budget_triggered", False)), + "degrade_reasons": trace_obj.get("degrade_reasons", []), + "data_gaps": list(data_gaps or []), + } diff --git a/ai-failure-analyzer/ai_failure_analyzer/services/sse.py b/ai-failure-analyzer/ai_failure_analyzer/services/sse.py new file mode 100644 index 0000000..bb6ae64 --- /dev/null +++ b/ai-failure-analyzer/ai_failure_analyzer/services/sse.py @@ -0,0 +1,13 @@ +"""SSE 文本帧格式化。""" + +import json +from typing import Any + + +def format_sse(event: str, data: Any) -> str: + """返回一条 SSE 消息(含结尾空行)。""" + if isinstance(data, (dict, list)): + payload = json.dumps(data, ensure_ascii=False) + else: + payload = str(data) + return f"event: {event}\ndata: {payload}\n\n" diff --git a/ai-failure-analyzer/pyproject.toml b/ai-failure-analyzer/pyproject.toml new file mode 100644 index 0000000..f458db8 --- /dev/null +++ b/ai-failure-analyzer/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ai-failure-analyzer" +version = "0.1.0" +description = "AIFA — AI 辅助失败原因分析独立服务(Phase A1)" +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "fastapi==0.115.6", + "uvicorn[standard]==0.32.1", + "pydantic==2.10.3", + "pydantic-settings==2.6.1", + "openai==1.57.4", + "httpx==0.28.1", + "selectolax==0.3.27", + "typing-extensions>=4.8.0", +] + +[project.optional-dependencies] +dev = [ + "pytest==8.3.4", + "pytest-asyncio==0.24.0", + "httpx==0.28.1", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["ai_failure_analyzer*"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] diff --git a/ai-failure-analyzer/requirements-dev.txt b/ai-failure-analyzer/requirements-dev.txt new file mode 100644 index 0000000..644dc67 --- /dev/null +++ b/ai-failure-analyzer/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==8.3.4 +pytest-asyncio==0.24.0 +httpx==0.28.1 diff --git a/ai-failure-analyzer/requirements.txt b/ai-failure-analyzer/requirements.txt new file mode 100644 index 0000000..ae6656d --- /dev/null +++ b/ai-failure-analyzer/requirements.txt @@ -0,0 +1,9 @@ +# 生产依赖(与 pyproject.toml 同步,便于 Docker pip install -r) +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +pydantic==2.10.3 +pydantic-settings==2.6.1 +openai==1.57.4 +httpx==0.28.1 +selectolax==0.3.27 +typing-extensions>=4.8.0 diff --git a/ai-failure-analyzer/tests/test_aifa_a1.py b/ai-failure-analyzer/tests/test_aifa_a1.py new file mode 100644 index 0000000..b9fe094 --- /dev/null +++ b/ai-failure-analyzer/tests/test_aifa_a1.py @@ -0,0 +1,427 @@ +"""A1 验收用例(不依赖外网 LLM)。""" + +import json +import uuid +from typing import Any, Dict, List, Tuple + +import pytest +from httpx import ASGITransport, AsyncClient + +from ai_failure_analyzer.main import app +from ai_failure_analyzer.services import analyze_service + + +def _parse_sse(body: str) -> List[Tuple[str, Dict[str, Any]]]: + events: List[Tuple[str, Dict[str, Any]]] = [] + for block in body.split("\n\n"): + block = block.strip() + if not block: + continue + event_name = None + data_payload = None + for line in block.split("\n"): + if line.startswith("event:"): + event_name = line[len("event:") :].strip() + elif line.startswith("data:"): + raw = line[len("data:") :].strip() + data_payload = json.loads(raw) + if event_name and data_payload is not None: + events.append((event_name, data_payload)) + return events + + +@pytest.fixture +def any_session_id() -> str: + return str(uuid.uuid4()) + + +@pytest.mark.asyncio +async def test_healthz(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.get("/healthz") + assert r.status_code == 200 + data = r.json() + assert data["status"] == "ok" + assert data["checks"]["report_fetch"] == "skipped" + assert data["checks"]["llm"] == "ok" + + +@pytest.mark.asyncio +async def test_healthz_llm_not_configured(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AIFA_LLM_MOCK", raising=False) + monkeypatch.delenv("AIFA_LLM_BASE_URL", raising=False) + monkeypatch.delenv("AIFA_LLM_API_KEY", raising=False) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.get("/healthz") + assert r.status_code == 200 + assert r.json()["checks"]["llm"] == "not_configured" + + +@pytest.mark.asyncio +async def test_analyze_missing_auth(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.post( + "/v1/analyze", + json={"session_id": str(uuid.uuid4()), "mode": "initial"}, + ) + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_analyze_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer wrong"}, + json={"session_id": str(uuid.uuid4()), "mode": "initial"}, + ) + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_analyze_mock_sse_and_category_guard( + monkeypatch: pytest.MonkeyPatch, + any_session_id: str, +) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer test-secret-token"}, + json={ + "session_id": any_session_id, + "mode": "initial", + "case_context": { + "case_name": "demo", + "batch": "b1", + "platform": "Android", + }, + }, + ) + assert r.status_code == 200 + assert "text/event-stream" in r.headers.get("content-type", "") + events = _parse_sse(r.text) + kinds = [e[0] for e in events] + assert kinds.count("progress") >= 4 + assert "report" in kinds + report_data = next(d for k, d in events if k == "report") + assert report_data["session_id"] == any_session_id + assert report_data["status"] == "partial" + assert report_data["report"]["failure_category"] == "unknown" + assert len(report_data["report"]["stage_timeline"]) >= 3 + assert any("plan" == item["stage"] for item in report_data["report"]["stage_timeline"]) + assert any("act" == item["stage"] for item in report_data["report"]["stage_timeline"]) + assert any("synthesis" == item["stage"] for item in report_data["report"]["stage_timeline"]) + assert any("截图" in g for g in report_data["report"]["data_gaps"]) + progress_stages = [d.get("stage") for k, d in events if k == "progress"] + assert "plan_started" in progress_stages + assert "plan_done" in progress_stages + assert "act_started" in progress_stages + assert "act_done" in progress_stages + assert "synthesize_started" in progress_stages + assert "synthesize_done" in progress_stages + + +@pytest.mark.asyncio +async def test_analyze_mock_keeps_spec_change_with_evidence( + monkeypatch: pytest.MonkeyPatch, + any_session_id: str, +) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer test-secret-token"}, + json={ + "session_id": any_session_id, + "mode": "initial", + "case_context": { + "case_name": "demo", + "success_screenshot_urls": ["http://example.com/a.png"], + }, + }, + ) + assert r.status_code == 200 + events = _parse_sse(r.text) + report_data = next(d for k, d in events if k == "report") + assert report_data["report"]["failure_category"] == "unknown" + + +@pytest.mark.asyncio +async def test_analyze_mock_keeps_spec_change_with_enough_compare_evidence( + monkeypatch: pytest.MonkeyPatch, + any_session_id: str, +) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + + async def fake_fetch_screenshot_b64(url: str, settings: object, **kwargs: object) -> Dict[str, Any]: + if "success" in url: + return { + "base64": "AAAA", + "mime": "image/png", + "size_bytes": 4, + "content_sha256_prefix": "same-hash", + "source_url": url, + } + return { + "base64": "BBBB", + "mime": "image/png", + "size_bytes": 4, + "content_sha256_prefix": "diff-hash", + "source_url": url, + } + + monkeypatch.setattr(analyze_service, "fetch_screenshot_b64", fake_fetch_screenshot_b64) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer test-secret-token"}, + json={ + "session_id": any_session_id, + "mode": "initial", + "case_context": { + "case_name": "demo", + "screenshot_urls": ["http://example.com/fail-1.png"], + "success_screenshot_urls": ["http://example.com/success-1.png"], + }, + }, + ) + assert r.status_code == 200 + events = _parse_sse(r.text) + report_data = next(d for k, d in events if k == "report") + assert report_data["report"]["failure_category"] == "规格变更,用例需适配" + + +@pytest.mark.asyncio +async def test_follow_up_with_existing_session( + monkeypatch: pytest.MonkeyPatch, + any_session_id: str, +) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + first = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer test-secret-token"}, + json={ + "session_id": any_session_id, + "mode": "initial", + "case_context": {"case_name": "demo"}, + }, + ) + assert first.status_code == 200 + follow = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer test-secret-token"}, + json={ + "session_id": any_session_id, + "mode": "follow_up", + "follow_up_message": "再解释一下结论", + }, + ) + assert follow.status_code == 200 + events = _parse_sse(follow.text) + report_data = next(d for k, d in events if k == "report") + assert report_data["session_id"] == any_session_id + assert report_data["trace"]["skills_invoked"] == ["synthesis_skill"] + + +@pytest.mark.asyncio +async def test_code_blame_prefers_last_success_batch_window( + monkeypatch: pytest.MonkeyPatch, + any_session_id: str, +) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + monkeypatch.setenv("AIFA_CODEHUB_BASE_URL", "https://codehub.example.com") + monkeypatch.setenv("AIFA_CODEHUB_TOKEN", "token") + captured: Dict[str, str] = {} + + async def fake_list( + settings: object, + repo_url: str, + branch: str, + since: str, + until: str, + path_filters: object = None, + limit: int = 30, + ) -> Dict[str, Any]: + captured["since"] = since + captured["until"] = until + return { + "commits": [ + { + "sha": "abc123", + "author": "alice", + "time": "2026-04-22T10:00:00", + "message": "fix", + "files": ["src/auth/a.py"], + } + ] + } + + async def fake_diff( + settings: object, + repo_url: str, + sha: str, + max_lines: int = 500, + ) -> Dict[str, Any]: + return {"diff": "+fix", "truncated": False, "line_count": 1, "files_changed": ["src/auth/a.py"]} + + monkeypatch.setattr(analyze_service, "codehub_list_commits", fake_list) + monkeypatch.setattr(analyze_service, "codehub_get_commit_diff", fake_diff) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer test-secret-token"}, + json={ + "session_id": any_session_id, + "mode": "initial", + "case_context": { + "case_name": "demo", + "start_time": "20260422_211001", + "last_success_batch": "20260421_211001", + }, + "repo_hint": { + "repo_url": "https://codehub.example.com/group/project", + "default_branch": "master", + "path_hints": ["src/auth/"], + }, + }, + ) + assert r.status_code == 200 + assert captured["since"] == "2026-04-21T21:10:01" + assert captured["until"] == "2026-04-22T21:10:01" + + +@pytest.mark.asyncio +async def test_follow_up_without_session_returns_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer test-secret-token"}, + json={ + "session_id": str(uuid.uuid4()), + "mode": "follow_up", + "follow_up_message": "是否是环境问题", + }, + ) + assert r.status_code == 200 + events = _parse_sse(r.text) + kinds = [k for k, _ in events] + assert "error" in kinds + err = next(d for k, d in events if k == "error") + assert err["error_code"] == "session_not_found" + + +@pytest.mark.asyncio +async def test_analyze_missing_session_id(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer test-secret-token"}, + json={"mode": "initial"}, + ) + assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_analyze_body_too_large(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + transport = ASGITransport(app=app) + huge = "x" * (600 * 1024) + payload = {"session_id": str(uuid.uuid4()), "mode": "initial", "case_context": {"case_name": huge}} + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer test-secret-token"}, + json=payload, + ) + assert r.status_code == 400 + + +@pytest.mark.asyncio +async def test_metrics_endpoint_after_analyze(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + transport = ASGITransport(app=app) + sid = str(uuid.uuid4()) + async with AsyncClient(transport=transport, base_url="http://test") as client: + analyze_resp = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer test-secret-token"}, + json={"session_id": sid, "mode": "initial", "case_context": {"history_id": 101, "case_name": "demo"}}, + ) + assert analyze_resp.status_code == 200 + metrics_resp = await client.get("/metrics") + assert metrics_resp.status_code == 200 + metrics = metrics_resp.json() + assert metrics["requests_total"] >= 1 + assert metrics["requests_ok"] + metrics["requests_partial"] + metrics["requests_error"] == metrics["requests_total"] + assert "tokens_total" in metrics + assert "estimated_cost_total" in metrics + + +@pytest.mark.asyncio +async def test_token_budget_circuit_breaker_returns_partial( + monkeypatch: pytest.MonkeyPatch, + any_session_id: str, +) -> None: + monkeypatch.setenv("AIFA_INTERNAL_TOKEN", "test-secret-token") + monkeypatch.setenv("AIFA_LLM_MOCK", "1") + monkeypatch.setenv("AIFA_MAX_TOKENS_PER_REQUEST", "10") + + async def fake_run_plan_stage( + payload: object, + settings: object, + trace: object, + ) -> Tuple[List[str], List[str]]: + trace.llm_input_tokens = 12 + return ["history_skill"], [] + + monkeypatch.setattr(analyze_service, "_run_plan_stage", fake_run_plan_stage) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + r = await client.post( + "/v1/analyze", + headers={"Authorization": "Bearer test-secret-token"}, + json={ + "session_id": any_session_id, + "mode": "initial", + "case_context": {"history_id": 202, "case_name": "demo"}, + }, + ) + assert r.status_code == 200 + events = _parse_sse(r.text) + report_data = next(d for k, d in events if k == "report") + assert report_data["status"] == "partial" + assert report_data["trace"]["token_budget_triggered"] is True + assert any("token" in g for g in report_data["report"]["data_gaps"]) diff --git a/ai-failure-analyzer/tests/test_b1_tools.py b/ai-failure-analyzer/tests/test_b1_tools.py new file mode 100644 index 0000000..13886db --- /dev/null +++ b/ai-failure-analyzer/tests/test_b1_tools.py @@ -0,0 +1,204 @@ +"""B1 证据拉取工具测试。""" + +import httpx +import pytest + +from ai_failure_analyzer.core.config import Settings +from ai_failure_analyzer.services.evidence_tools import ( + build_success_urls_by_batch_replace, + fetch_report_html, + fetch_screenshot_b64, + resolve_evidence_urls, +) + + +def _settings() -> Settings: + return Settings( + AIFA_FETCH_ALLOWED_HOSTS="example.com", + AIFA_FETCH_CONNECT_TIMEOUT_SECONDS=1, + AIFA_FETCH_READ_TIMEOUT_SECONDS=1, + AIFA_REPORT_MAX_CHARS=20, + AIFA_SCREENSHOT_MAX_BYTES=1024, + AIFA_SCREENSHOT_MAX_IMAGES=3, + ) + + +@pytest.mark.asyncio +async def test_fetch_report_html_success_and_truncate(monkeypatch: pytest.MonkeyPatch) -> None: + html = "

Title

abcdefg0123456789ZZZZ

" + + async def fake_get(self: httpx.AsyncClient, url: str) -> httpx.Response: + request = httpx.Request("GET", url) + return httpx.Response(200, headers={"content-type": "text/html"}, text=html, request=request) + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + result = await fetch_report_html("https://example.com/report.html", settings=_settings()) + assert "error" not in result + assert result["truncated"] is True + assert len(str(result["text"])) == 20 + + +@pytest.mark.asyncio +async def test_fetch_report_html_non_html(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_get(self: httpx.AsyncClient, url: str) -> httpx.Response: + request = httpx.Request("GET", url) + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + text='{"ok":true}', + request=request, + ) + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + result = await fetch_report_html("https://example.com/report.json", settings=_settings()) + assert result["error"] == "unsupported_content_type" + + +@pytest.mark.asyncio +async def test_fetch_screenshot_b64_direct_image(monkeypatch: pytest.MonkeyPatch) -> None: + binary = b"\x89PNG\r\n\x1a\nxxxx" + + async def fake_get(self: httpx.AsyncClient, url: str) -> httpx.Response: + request = httpx.Request("GET", url) + return httpx.Response(200, headers={"content-type": "image/png"}, content=binary, request=request) + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + result = await fetch_screenshot_b64("https://example.com/a.png", settings=_settings()) + assert "error" not in result + assert result["mime"] == "image/png" + assert result["size_bytes"] == len(binary) + + +@pytest.mark.asyncio +async def test_fetch_screenshot_b64_index_html(monkeypatch: pytest.MonkeyPatch) -> None: + index_html = """ + + + + pic + + + """ + + async def fake_get(self: httpx.AsyncClient, url: str) -> httpx.Response: + request = httpx.Request("GET", url) + if url.endswith(".png") or url.endswith(".jpg"): + return httpx.Response( + 200, + headers={"content-type": "image/png"}, + content=b"img", + request=request, + ) + return httpx.Response(200, headers={"content-type": "text/html"}, text=index_html, request=request) + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + result = await fetch_screenshot_b64("https://example.com/index.html", settings=_settings()) + assert "error" not in result + assert result["total_found"] == 4 + assert result["selected_count"] == 3 + assert result["image_count"] == 3 + assert result["truncated_by_max_images"] is True + + +@pytest.mark.asyncio +async def test_fetch_screenshot_rejects_unallowed_host() -> None: + result = await fetch_screenshot_b64("https://evil.com/a.png", settings=_settings()) + assert result["error"] == "url_host_not_allowed" + + +@pytest.mark.asyncio +async def test_resolve_evidence_urls_prefilled_urls_have_priority(monkeypatch: pytest.MonkeyPatch) -> None: + called = {"count": 0} + + async def fake_get(self: httpx.AsyncClient, url: str) -> httpx.Response: + called["count"] += 1 + request = httpx.Request("GET", url) + return httpx.Response(200, headers={"content-type": "text/html"}, text="", request=request) + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + result = await resolve_evidence_urls( + settings=_settings(), + reports_url="https://example.com/r.html", + screenshot_urls=["https://example.com/a.png", "https://example.com/a.png#dup"], + screenshot_index_url="https://example.com/index.html", + ) + assert result["report_url"] == "https://example.com/r.html" + assert result["screenshot_urls"] == ["https://example.com/a.png"] + meta = result["url_resolution_meta"] + assert meta["source"] == "prefilled_urls" + assert called["count"] == 0 + + +@pytest.mark.asyncio +async def test_resolve_evidence_urls_extracts_relative_paths_from_index(monkeypatch: pytest.MonkeyPatch) -> None: + html = """ + + + + x + skip + + """ + + async def fake_get(self: httpx.AsyncClient, url: str) -> httpx.Response: + request = httpx.Request("GET", url) + return httpx.Response(200, headers={"content-type": "text/html"}, text=html, request=request) + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + result = await resolve_evidence_urls( + settings=_settings(), + reports_url=None, + screenshot_urls=[], + screenshot_index_url="https://example.com/a/b/index.html", + max_screenshot_candidates=2, + ) + assert result["screenshot_urls"] == [ + "https://example.com/a/images/1.png", + "https://example.com/3.webp", + ] + meta = result["url_resolution_meta"] + assert meta["source"] == "index_page" + assert meta["truncated"] is True + + +@pytest.mark.asyncio +async def test_resolve_evidence_urls_rejects_unallowed_index_host() -> None: + result = await resolve_evidence_urls( + settings=_settings(), + reports_url=None, + screenshot_urls=[], + screenshot_index_url="https://evil.com/index.html", + ) + assert result["screenshot_urls"] == [] + errors = result["errors"] + assert isinstance(errors, list) + assert any(item["code"] == "url_host_not_allowed" for item in errors) + + +def test_build_success_urls_by_batch_replace_success() -> None: + result = build_success_urls_by_batch_replace( + settings=_settings(), + failed_urls=[ + "https://example.com/reports/batch_20260401/case/screenshots/1.png", + "https://example.com/reports/batch_20260401/case/screenshots/2.png", + ], + failed_batch="20260401", + success_batch="20260331", + ) + assert result["success_urls"] == [ + "https://example.com/reports/batch_20260331/case/screenshots/1.png", + "https://example.com/reports/batch_20260331/case/screenshots/2.png", + ] + assert result["errors"] == [] + + +def test_build_success_urls_by_batch_replace_not_applicable() -> None: + result = build_success_urls_by_batch_replace( + settings=_settings(), + failed_urls=["https://example.com/reports/no_batch_marker/1.png"], + failed_batch="20260401", + success_batch="20260331", + ) + assert result["success_urls"] == [] + assert any(item["code"] == "batch_replace_not_applicable" for item in result["errors"]) + diff --git a/ai-failure-analyzer/tests/test_b5_codehub.py b/ai-failure-analyzer/tests/test_b5_codehub.py new file mode 100644 index 0000000..267439c --- /dev/null +++ b/ai-failure-analyzer/tests/test_b5_codehub.py @@ -0,0 +1,112 @@ +"""B5 CodeHub 工具测试。""" + +import httpx +import pytest + +from ai_failure_analyzer.core.config import Settings +from ai_failure_analyzer.services.evidence_tools import ( + CodeHubAuthError, + codehub_get_commit_diff, + codehub_list_commits, +) + + +def _settings() -> Settings: + return Settings( + AIFA_CODEHUB_BASE_URL="https://codehub.example.com", + AIFA_CODEHUB_TOKEN="token", + AIFA_CODEHUB_CONNECT_TIMEOUT_SECONDS=1, + AIFA_CODEHUB_READ_TIMEOUT_SECONDS=1, + ) + + +@pytest.mark.asyncio +async def test_codehub_list_commits_success(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_get( + self: httpx.AsyncClient, + url: str, + params: object = None, + headers: object = None, + ) -> httpx.Response: + request = httpx.Request("GET", url) + assert "commits" in url + payload = { + "commits": [ + { + "sha": "abc123", + "author_name": "alice", + "committed_at": "2026-04-22T21:10:01", + "message": "fix auth bug", + "files": ["src/auth/login.py"], + } + ] + } + return httpx.Response(200, json=payload, request=request) + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + result = await codehub_list_commits( + settings=_settings(), + repo_url="https://codehub.example.com/group/proj", + branch="master", + since="2026-04-21T21:10:01", + until="2026-04-22T21:10:01", + path_filters=["src/auth/"], + limit=30, + ) + assert "error" not in result + commits = result["commits"] + assert isinstance(commits, list) + assert commits[0]["sha"] == "abc123" + assert commits[0]["author"] == "alice" + + +@pytest.mark.asyncio +async def test_codehub_list_commits_401_fail_loud(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_get( + self: httpx.AsyncClient, + url: str, + params: object = None, + headers: object = None, + ) -> httpx.Response: + request = httpx.Request("GET", url) + return httpx.Response(401, json={"message": "unauthorized"}, request=request) + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + with pytest.raises(CodeHubAuthError): + await codehub_list_commits( + settings=_settings(), + repo_url="https://codehub.example.com/group/proj", + branch="master", + since="2026-04-21T21:10:01", + until="2026-04-22T21:10:01", + path_filters=[], + limit=30, + ) + + +@pytest.mark.asyncio +async def test_codehub_get_commit_diff_truncate(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_get( + self: httpx.AsyncClient, + url: str, + params: object = None, + headers: object = None, + ) -> httpx.Response: + request = httpx.Request("GET", url) + payload = { + "diff": "a\nb\nc\nd\ne", + "files_changed": ["src/a.py"], + } + return httpx.Response(200, json=payload, request=request) + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + result = await codehub_get_commit_diff( + settings=_settings(), + repo_url="https://codehub.example.com/group/proj", + sha="abc123", + max_lines=3, + ) + assert "error" not in result + assert result["truncated"] is True + assert str(result["diff"]).splitlines() == ["a", "b", "c"] + assert result["files_changed"] == ["src/a.py"] diff --git a/backend/api/v1/analysis.py b/backend/api/v1/analysis.py index af83184..01253bd 100644 --- a/backend/api/v1/analysis.py +++ b/backend/api/v1/analysis.py @@ -1,13 +1,148 @@ -from fastapi import APIRouter +# ============================================================ +# API — AI 失败分析(A4 接受/拒绝 + A5 分析入口限流) +# ============================================================ -router = APIRouter(prefix="/analysis", tags=["失败分析"]) +import uuid +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession -@router.get("") -async def list_analysis(): - return {"message": "TODO"} +from backend.core.config import settings +from backend.core.database import get_db +from backend.core.dependencies import require_apply_failure_reason_permission +from backend.schemas.analysis import ( + AnalyzeRequest, + ApplyFailureReasonRequest, + ApplyFailureReasonResponse, + RejectFailureReasonRequest, + RejectFailureReasonResponse, +) +from backend.services.ai_context_builder import AIContextHistoryNotFoundError, build_analyze_payload +from backend.services.ai_rate_limit_service import HistoryAnalyzeRateLimiter, log_rate_limit_hit +from backend.services.analysis_service import apply_ai_failure_reason, reject_ai_failure_reason +router = APIRouter(prefix="/ai", tags=["AI失败分析"]) +_analyze_rate_limiter = HistoryAnalyzeRateLimiter( + window_seconds=settings.AI_ANALYZE_RATE_LIMIT_WINDOW_SECONDS, + max_requests=settings.AI_ANALYZE_RATE_LIMIT_MAX_REQUESTS, +) -@router.post("") -async def create_analysis(): - return {"message": "TODO"} + +@router.post("/analyze") +async def post_analyze( + req: AnalyzeRequest, + request: Request, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(require_apply_failure_reason_permission), +): + user_employee_id = str(payload.get("sub", "")).strip() + allow, current_count = _analyze_rate_limiter.try_acquire(req.history_id) + if not allow: + log_rate_limit_hit( + history_id=req.history_id, + user_employee_id=user_employee_id, + session_id=req.session_id, + mode=req.mode, + window_seconds=settings.AI_ANALYZE_RATE_LIMIT_WINDOW_SECONDS, + threshold=settings.AI_ANALYZE_RATE_LIMIT_MAX_REQUESTS, + current_count=current_count, + ) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail={ + "code": "AI_ANALYZE_RATE_LIMITED", + "message": "同一失败记录在 1 分钟内最多发起 10 次分析,请稍后重试", + "history_id": req.history_id, + }, + ) + + try: + payload_built = await build_analyze_payload(db, req.history_id) + except AIContextHistoryNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"执行记录不存在: history_id={req.history_id}", + ) + + if req.mode == "follow_up" and not (req.follow_up_message or "").strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="follow_up 模式必须提供 follow_up_message") + + session_id = (req.session_id or "").strip() or str(uuid.uuid4()) + forward_payload = { + "session_id": session_id, + "mode": req.mode, + "follow_up_message": req.follow_up_message, + **payload_built, + } + if not (settings.AIFA_INTERNAL_TOKEN or "").strip(): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="AIFA_INTERNAL_TOKEN 未配置") + aifa_url = settings.AIFA_BASE_URL.rstrip("/") + "/v1/analyze" + request_id = getattr(request.state, "request_id", "") or str(uuid.uuid4()) + headers = { + "Authorization": f"Bearer {settings.AIFA_INTERNAL_TOKEN}", + "X-Request-ID": request_id, + } + timeout = httpx.Timeout(settings.AI_ANALYZE_TIMEOUT_SECONDS) + client = httpx.AsyncClient(timeout=timeout) + try: + request_obj = client.build_request("POST", aifa_url, json=forward_payload, headers=headers) + upstream = await client.send(request_obj, stream=True) + except httpx.HTTPError as exc: + await client.aclose() + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"AIFA 调用失败: {str(exc)}") + + if upstream.status_code != status.HTTP_200_OK: + try: + detail_bytes = await upstream.aread() + msg = detail_bytes.decode("utf-8", errors="ignore") + except Exception: + msg = "" + await upstream.aclose() + await client.aclose() + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"AIFA 返回异常状态({upstream.status_code}){': ' + msg if msg else ''}", + ) + + async def stream_body(): + try: + async for chunk in upstream.aiter_bytes(): + if chunk: + yield chunk + finally: + await upstream.aclose() + await client.aclose() + + return StreamingResponse( + stream_body(), + media_type="text/event-stream; charset=utf-8", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Request-ID": request_id, + }, + ) + + +@router.post("/apply-failure-reason", response_model=ApplyFailureReasonResponse) +async def post_apply_failure_reason( + req: ApplyFailureReasonRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(require_apply_failure_reason_permission), +): + """用户确认后将 AI 结论写入 pipeline_failure_reason,并同步 pipeline_history.analyzed。""" + analyzer_employee_id = str(payload.get("sub", "")).strip() + return await apply_ai_failure_reason(db, req, analyzer_employee_id) + + +@router.post("/reject-failure-reason", response_model=RejectFailureReasonResponse) +async def post_reject_failure_reason( + req: RejectFailureReasonRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(require_apply_failure_reason_permission), +): + """拒绝 AI 草稿:不写业务表,仅记录审计。""" + operator_employee_id = str(payload.get("sub", "")).strip() + return await reject_ai_failure_reason(db, req, operator_employee_id) diff --git a/backend/core/config.py b/backend/core/config.py index 3c52b08..6e7c5fc 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -31,6 +31,18 @@ class Settings(BaseSettings): # 站点对外根 URL(无尾部斜杠),用于一键通知 WeLink 卡片内 /history 绝对链接 PUBLIC_APP_URL: str = "" + # AI 失败分析:main_module → 仓库映射(YAML,模板见 config/module_repo_mapping.yaml.example) + AI_MODULE_REPO_MAPPING_PATH: str = "" + # AI 失败分析:AIFA 服务地址(不含 /v1/analyze) + AIFA_BASE_URL: str = "http://127.0.0.1:8080" + # dt-report -> AIFA 内部鉴权 token + AIFA_INTERNAL_TOKEN: str = "" + # A5 限流:单 history_id 窗口(秒)与阈值 + AI_ANALYZE_RATE_LIMIT_WINDOW_SECONDS: int = 60 + AI_ANALYZE_RATE_LIMIT_MAX_REQUESTS: int = 10 + # dt-report -> AIFA 单次调用超时(秒) + AI_ANALYZE_TIMEOUT_SECONDS: int = 180 + # LDAP 域登录(LDAP_HOST 留空则使用 MVP 密码模式) LDAP_HOST: str = "" LDAP_PORT: int = 389 diff --git a/backend/core/dependencies.py b/backend/core/dependencies.py index 51d6f96..8cc1a0d 100644 --- a/backend/core/dependencies.py +++ b/backend/core/dependencies.py @@ -4,6 +4,7 @@ from backend.core.database import get_db from backend.core.security import verify_token +from backend.services.auth_service import get_user_role bearer_scheme = HTTPBearer(auto_error=False) @@ -17,3 +18,19 @@ async def get_current_user(token=Depends(bearer_scheme)) -> dict: if token is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") return verify_token(token.credentials) + + +async def require_apply_failure_reason_permission( + payload: dict = Depends(get_current_user), +) -> dict: + """ + A4 写库权限依赖。 + 当前策略:登录用户(user/admin)均可执行;保留显式授权检查点,便于后续收紧。 + """ + employee_id = str(payload.get("sub", "")).strip() + if not employee_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + role = get_user_role(employee_id) + if role not in ("user", "admin"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限执行该操作") + return payload diff --git a/backend/requirements.txt b/backend/requirements.txt index 69c2e07..c931e48 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,5 +8,6 @@ python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 python-multipart==0.0.20 httpx==0.28.1 +PyYAML==6.0.2 ldap3==2.9.1 playwright==1.48.0 diff --git a/backend/schemas/analysis.py b/backend/schemas/analysis.py index 3e0546a..20acf11 100644 --- a/backend/schemas/analysis.py +++ b/backend/schemas/analysis.py @@ -1,9 +1,42 @@ -from pydantic import BaseModel +from typing import Literal, Optional +from pydantic import BaseModel, Field -class FailureReasonCreateRequest(BaseModel): - pass +class ApplyFailureReasonRequest(BaseModel): + history_id: int = Field(..., ge=1, description="执行历史 ID") + failure_category: str = Field(..., min_length=1, max_length=255, description="A3 输出的失败分类") + detailed_reason: str = Field(..., min_length=1, max_length=2000, description="A3 输出的详细原因") + session_id: Optional[str] = Field(None, max_length=100, description="AIFA 会话 ID") + analysis_draft_id: Optional[str] = Field(None, max_length=100, description="分析草稿 ID,用于幂等防重放") + version: Optional[str] = Field(None, max_length=100, description="版本戳") + nonce: Optional[str] = Field(None, max_length=100, description="随机串") -class FailureReasonResponse(BaseModel): - pass + +class ApplyFailureReasonResponse(BaseModel): + success: bool = True + history_id: int + applied: bool + analyzed_updated: bool + message: str + + +class RejectFailureReasonRequest(BaseModel): + history_id: int = Field(..., ge=1, description="执行历史 ID") + session_id: Optional[str] = Field(None, max_length=100, description="AIFA 会话 ID") + analysis_draft_id: Optional[str] = Field(None, max_length=100, description="分析草稿 ID") + reason: Optional[str] = Field(None, max_length=500, description="拒绝原因(可选)") + + +class RejectFailureReasonResponse(BaseModel): + success: bool = True + history_id: int + rejected: bool = True + message: str = "已拒绝本次分析结果" + + +class AnalyzeRequest(BaseModel): + history_id: int = Field(..., ge=1, description="执行历史 ID") + mode: Literal["initial", "follow_up"] = Field("initial", description="分析模式") + session_id: Optional[str] = Field(None, max_length=100, description="AIFA 会话 ID") + follow_up_message: Optional[str] = Field(None, max_length=2000, description="追问内容") diff --git a/backend/services/ai_context_builder.py b/backend/services/ai_context_builder.py new file mode 100644 index 0000000..1de6bb8 --- /dev/null +++ b/backend/services/ai_context_builder.py @@ -0,0 +1,173 @@ +# ============================================================ +# AI 分析上下文 — 仅只读拼装发往 AIFA 的 JSON(无 LLM、无 log_url) +# 规格:docs/superpowers/specs/dt-report-phase-a2-ai-context-builder-spec.md +# ============================================================ + +import logging +from functools import lru_cache +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import yaml +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.core.config import settings +from backend.models.pipeline_history import PipelineHistory +from backend.services.history_service import list_recent_executions_by_case_platform + +logger = logging.getLogger(__name__) + +# A2:与规格默认 N=20 一致;单字段截断防止 token 膨胀 +RECENT_EXECUTIONS_LIMIT = 20 +MAX_STRING_CHARS = 2000 +MAX_PATH_HINTS = 50 +MAX_PATH_HINT_LEN = 500 + + +class AIContextHistoryNotFoundError(Exception): + """pipeline_history 不存在时由 API 层转换为 404。""" + + def __init__(self, history_id: int) -> None: + self.history_id = history_id + super().__init__(f"执行记录不存在: history_id={history_id}") + + +def _truncate(value: Optional[str], max_chars: int = MAX_STRING_CHARS) -> Optional[str]: + if value is None: + return None + s = str(value).strip() + if not s: + return None + if len(s) <= max_chars: + return s + return s[: max_chars - 3] + "..." + + +@lru_cache(maxsize=8) +def _parse_module_repo_mapping_file(resolved_path: str) -> Tuple[Dict[str, Any], ...]: + """ + 解析 config/module_repo_mapping.yaml(或部署路径)。 + 路径为空或文件不存在时返回空元组;解析失败时 WARNING 并返回空。 + """ + if not resolved_path: + return () + p = Path(resolved_path) + if not p.is_file(): + return () + try: + raw = p.read_text(encoding="utf-8") + data = yaml.safe_load(raw) + except Exception: + logger.warning("module_repo_mapping 文件无法解析: path=%s", resolved_path, exc_info=True) + return () + if not data or not isinstance(data, dict): + return () + mappings = data.get("mappings") + if not isinstance(mappings, list): + return () + out: List[Dict[str, Any]] = [] + for m in mappings: + if isinstance(m, dict): + out.append(m) + return tuple(out) + + +def _repo_hint_for_main_module(main_module: Optional[str]) -> Dict[str, Any]: + mm = (main_module or "").strip() + if not mm: + return {} + path = (settings.AI_MODULE_REPO_MAPPING_PATH or "").strip() + if not path: + return {} + try: + resolved = str(Path(path).resolve()) + except Exception: + resolved = path + for m in _parse_module_repo_mapping_file(resolved): + if str(m.get("main_module", "")).strip() != mm: + continue + rh: Dict[str, Any] = {} + ru = m.get("repo_url") + if ru is not None and str(ru).strip(): + rh["repo_url"] = _truncate(str(ru), MAX_STRING_CHARS) + db = m.get("default_branch") + if db is not None and str(db).strip(): + rh["default_branch"] = _truncate(str(db), 512) + ph = m.get("path_hints") + if isinstance(ph, list): + hints: List[str] = [] + for h in ph[:MAX_PATH_HINTS]: + if h is None: + continue + t = _truncate(str(h), MAX_PATH_HINT_LEN) + if t: + hints.append(t) + if hints: + rh["path_hints"] = hints + return rh + return {} + + +def _case_context_from_row(row: PipelineHistory) -> Dict[str, Any]: + """构造 case_context;禁止包含 log_url。""" + ctx: Dict[str, Any] = { + "history_id": row.id, + "batch": _truncate(row.start_time), + "case_name": _truncate(row.case_name), + "platform": _truncate(row.platform), + "main_module": _truncate(row.main_module) or "", + "module": _truncate(row.module), + "subtask": _truncate(row.subtask), + # 与 batch 同源(轮次);便于与旧字段名兼容 + "start_time": _truncate(row.start_time), + "case_result": _truncate(row.case_result), + "code_branch": _truncate(row.code_branch), + "pipeline_url": _truncate(row.pipeline_url), + "reports_url": _truncate(row.reports_url), + "case_level": _truncate(row.case_level) or "", + } + su = _truncate(row.screenshot_url, MAX_STRING_CHARS) + if su: + ctx["screenshot_index_url"] = su + ctx["screenshot_urls"] = [su] + # A2:成功侧 batch/URL 替换见 Phase B4;此处不伪造 + return {k: v for k, v in ctx.items() if v is not None and v != ""} + + +async def build_analyze_payload(db: AsyncSession, history_id: int) -> Dict[str, Any]: + """ + 拼装发往 AIFA 的 JSON 片段(不含 session_id/mode;由 ai_proxy 合并)。 + 禁止包含日志 HTML URL。 + """ + stmt = select(PipelineHistory).where(PipelineHistory.id == history_id) + result = await db.execute(stmt) + row = result.scalar_one_or_none() + if row is None: + raise AIContextHistoryNotFoundError(history_id) + + case_context = _case_context_from_row(row) + recent = await list_recent_executions_by_case_platform( + db, + row.case_name, + row.platform, + RECENT_EXECUTIONS_LIMIT, + ) + recent_executions: List[Dict[str, Optional[str]]] = [] + for item in recent: + recent_executions.append( + { + "start_time": _truncate(item.get("start_time")), + "case_result": _truncate(item.get("case_result")), + "code_branch": _truncate(item.get("code_branch")), + } + ) + + repo_hint = _repo_hint_for_main_module(row.main_module) + + payload: Dict[str, Any] = { + "case_context": case_context, + "recent_executions": recent_executions, + "repo_hint": repo_hint, + } + return payload diff --git a/backend/services/ai_rate_limit_service.py b/backend/services/ai_rate_limit_service.py new file mode 100644 index 0000000..6d1b7aa --- /dev/null +++ b/backend/services/ai_rate_limit_service.py @@ -0,0 +1,52 @@ +import logging +import threading +import time +from collections import deque +from typing import Deque, Dict, Optional, Tuple + + +logger = logging.getLogger(__name__) + + +class HistoryAnalyzeRateLimiter: + def __init__(self, window_seconds: int, max_requests: int) -> None: + self.window_seconds = max(1, int(window_seconds)) + self.max_requests = max(1, int(max_requests)) + self._events: Dict[int, Deque[float]] = {} + self._lock = threading.Lock() + + def try_acquire(self, history_id: int, now: Optional[float] = None) -> Tuple[bool, int]: + current = now if now is not None else time.time() + with self._lock: + q = self._events.setdefault(history_id, deque()) + cutoff = current - self.window_seconds + while q and q[0] <= cutoff: + q.popleft() + + if len(q) >= self.max_requests: + return False, len(q) + + q.append(current) + return True, len(q) + + +def log_rate_limit_hit( + *, + history_id: int, + user_employee_id: str, + session_id: Optional[str], + mode: str, + window_seconds: int, + threshold: int, + current_count: int, +) -> None: + logger.warning( + "AI 分析限流命中 history_id=%s user=%s session_id=%s mode=%s window_seconds=%s threshold=%s current_count=%s", + history_id, + user_employee_id, + session_id or "", + mode, + window_seconds, + threshold, + current_count, + ) diff --git a/backend/services/analysis_service.py b/backend/services/analysis_service.py index fc5f2a4..c88a9e3 100644 --- a/backend/services/analysis_service.py +++ b/backend/services/analysis_service.py @@ -1,2 +1,545 @@ -class AnalysisService: - pass +# ============================================================ +# AI 失败分析 — A4 接受/拒绝写库(apply / reject) +# 规约:docs/superpowers/specs/aifa-phase-a4-apply-failure-reason-spec.md +# ============================================================ + +import logging +import time +from typing import Optional, Set + +from fastapi import HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.models.case_failed_type import CaseFailedType +from backend.models.pipeline_failure_reason import PipelineFailureReason +from backend.models.pipeline_history import PipelineHistory +from backend.models.ums_email import UmsEmail +from backend.schemas.analysis import ( + ApplyFailureReasonRequest, + ApplyFailureReasonResponse, + RejectFailureReasonRequest, + RejectFailureReasonResponse, +) +from backend.services.case_dev_owner_helpers import ( + build_module_to_case_dev_owner_display, + case_dev_owner_display_for_row, + format_case_dev_owner_display, +) +from backend.services.failed_type_helpers import get_bug_failed_type_value +from backend.utils.audit import build_audit_detail, write_audit_log + +logger = logging.getLogger(__name__) + +ph = PipelineHistory +pfr = PipelineFailureReason + +PFR_OWNER_MAX_LEN = 100 +DETAILED_REASON_MAX_LEN = 2000 + +# 与 A3 `report.failure_category` 对齐(不含 unknown;unknown 禁止一键入库) +AIFA_REPORT_FAILURE_CATEGORIES = frozenset( + { + "bug", + "环境问题", + "规格变更,用例需适配", + "用例不稳定,需加固", + "unknown", + } +) + + +def _strip_or_empty(val: Optional[str]) -> str: + return (val or "").strip() + + +def _anti_replay_present(req: ApplyFailureReasonRequest) -> bool: + return any( + [ + _strip_or_empty(req.session_id), + _strip_or_empty(req.analysis_draft_id), + _strip_or_empty(req.version), + _strip_or_empty(req.nonce), + ] + ) + + +async def _audit( + db: AsyncSession, + *, + operator: str, + action: str, + history_id: int, + result_status: str, + elapsed_ms: float, + failure_category: Optional[str] = None, + session_id: Optional[str] = None, + analysis_draft_id: Optional[str] = None, + version: Optional[str] = None, + nonce: Optional[str] = None, + message: Optional[str] = None, + extra: Optional[dict] = None, +) -> None: + detail_obj = { + "user_employee_id": operator, + "history_id": history_id, + "session_id": session_id, + "action": action, + "result_status": result_status, + "failure_category": failure_category, + "analysis_draft_id": analysis_draft_id, + "version": version, + "nonce": nonce, + "elapsed_ms": int(elapsed_ms), + "message": message, + } + if extra: + detail_obj.update(extra) + await write_audit_log( + db, + operator=operator, + action=action, + target_type="pipeline_history", + target_id=str(history_id), + detail=build_audit_detail(detail_obj), + ) + + +async def _canonical_failed_type_value(db: AsyncSession, failure_category: str) -> str: + """ + 将 A3 的 failure_category 解析为 case_failed_type.failed_reason_type 的库内原值(同值优先,其次大小写不敏感匹配)。 + """ + raw = failure_category.strip() + if raw.lower() == "unknown": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="failure_category 为 unknown 时不允许一键入库,请先人工复核", + ) + if raw not in AIFA_REPORT_FAILURE_CATEGORIES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="failure_category 不在 AIFA 允许子集内", + ) + + stmt = select(CaseFailedType.failed_reason_type) + res = await db.execute(stmt) + db_types = [r[0] for r in res.all() if r[0]] + if raw in db_types: + return raw + lowered = {t.lower(): t for t in db_types} + key = raw.lower() + if key in lowered: + return lowered[key] + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="failure_category 在库内未配置对应失败类型,请联系管理员", + ) + + +async def _owner_for_non_bug(db: AsyncSession, mapped_failed_type: str) -> str: + stmt = select(CaseFailedType).where( + func.lower(func.trim(CaseFailedType.failed_reason_type)) == mapped_failed_type.strip().lower() + ) + res = await db.execute(stmt) + row = res.scalars().first() + if not row: + logger.error("case_failed_type 缺失记录 mapped_failed_type=%s", mapped_failed_type) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="系统未配置失败类型映射,请联系管理员", + ) + eid = _strip_or_empty(row.owner) + if not eid: + logger.error("case_failed_type.owner 未配置 mapped_failed_type=%s", mapped_failed_type) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="系统未为该失败类型配置默认跟踪人,请联系管理员", + ) + em_res = await db.execute(select(UmsEmail).where(UmsEmail.employee_id == eid)) + em = em_res.scalars().first() + name = (em.name or "").strip() if em else "" + display = format_case_dev_owner_display(name or None, eid) or eid + if len(display) > PFR_OWNER_MAX_LEN: + logger.warning("AI 一键入库:跟踪人超长已截断 len=%d", len(display)) + display = display[:PFR_OWNER_MAX_LEN] + return display + + +async def _owner_for_bug(db: AsyncSession, history_row: PipelineHistory) -> str: + mm = _strip_or_empty(history_row.main_module) + if not mm: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="主模块为空,无法解析 bug 跟踪人", + ) + modules: Set[str] = {mm} + module_to_display = await build_module_to_case_dev_owner_display(db, modules) + owner_str = case_dev_owner_display_for_row(history_row, module_to_display) + if not owner_str: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无法解析 bug 跟踪人:请确认主模块是否配置模块负责人", + ) + if len(owner_str) > PFR_OWNER_MAX_LEN: + logger.warning("AI 一键入库:跟踪人超长已截断 case_name=%r", history_row.case_name) + owner_str = owner_str[:PFR_OWNER_MAX_LEN] + return owner_str + + +def _normalize_detailed_reason(text: str) -> str: + s = (text or "").strip() + if not s: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="detailed_reason 不能为空", + ) + if len(s) > DETAILED_REASON_MAX_LEN: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"detailed_reason 长度超过上限({DETAILED_REASON_MAX_LEN})", + ) + return s + + +def _pfr_matches( + existing: PipelineFailureReason, + *, + failed_type: str, + reason: str, + owner: str, + analyzer: str, +) -> bool: + same_type = (existing.failed_type or "").strip().lower() == (failed_type or "").strip().lower() + same_reason = (existing.reason or "").strip() == reason.strip() + same_owner = (existing.owner or "").strip() == owner.strip() + same_analyzer = (existing.analyzer or "").strip() == (analyzer or "").strip() + return same_type and same_reason and same_owner and same_analyzer + + +async def apply_ai_failure_reason( + db: AsyncSession, + req: ApplyFailureReasonRequest, + analyzer_employee_id: str, +) -> ApplyFailureReasonResponse: + t0 = time.perf_counter() + action = "apply_failure_reason" + hid = req.history_id + + if not _anti_replay_present(req): + elapsed = (time.perf_counter() - t0) * 1000 + await _audit( + db, + operator=analyzer_employee_id, + action=action, + history_id=hid, + result_status="denied", + elapsed_ms=elapsed, + failure_category=req.failure_category, + session_id=_strip_or_empty(req.session_id) or None, + analysis_draft_id=_strip_or_empty(req.analysis_draft_id) or None, + version=_strip_or_empty(req.version) or None, + nonce=_strip_or_empty(req.nonce) or None, + message="缺少防伪参数", + ) + await db.commit() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="缺少防伪参数:请提供 session_id、analysis_draft_id、version、nonce 至少一项", + ) + + try: + try: + mapped_failed_type = await _canonical_failed_type_value(db, req.failure_category) + reason_text = _normalize_detailed_reason(req.detailed_reason) + except HTTPException as exc: + elapsed = (time.perf_counter() - t0) * 1000 + detail = exc.detail + msg = detail if isinstance(detail, str) else "参数校验失败" + await _audit( + db, + operator=analyzer_employee_id, + action=action, + history_id=hid, + result_status="denied", + elapsed_ms=elapsed, + failure_category=req.failure_category, + session_id=_strip_or_empty(req.session_id) or None, + analysis_draft_id=_strip_or_empty(req.analysis_draft_id) or None, + version=_strip_or_empty(req.version) or None, + nonce=_strip_or_empty(req.nonce) or None, + message=msg, + ) + await db.commit() + raise exc + + bug_val = await get_bug_failed_type_value(db) + if not bug_val: + logger.error("case_failed_type 未配置 bug") + elapsed = (time.perf_counter() - t0) * 1000 + await _audit( + db, + operator=analyzer_employee_id, + action=action, + history_id=hid, + result_status="failed", + elapsed_ms=elapsed, + failure_category=req.failure_category, + session_id=_strip_or_empty(req.session_id) or None, + analysis_draft_id=_strip_or_empty(req.analysis_draft_id) or None, + version=_strip_or_empty(req.version) or None, + nonce=_strip_or_empty(req.nonce) or None, + message="系统未配置 bug 失败类型", + ) + await db.commit() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="系统未配置失败类型 bug,请联系管理员", + ) + + stmt = select(ph).where(ph.id == hid).with_for_update() + res = await db.execute(stmt) + history_row = res.scalars().first() + if not history_row: + elapsed = (time.perf_counter() - t0) * 1000 + await _audit( + db, + operator=analyzer_employee_id, + action=action, + history_id=hid, + result_status="failed", + elapsed_ms=elapsed, + failure_category=req.failure_category, + session_id=_strip_or_empty(req.session_id) or None, + analysis_draft_id=_strip_or_empty(req.analysis_draft_id) or None, + version=_strip_or_empty(req.version) or None, + nonce=_strip_or_empty(req.nonce) or None, + message="history 不存在", + ) + await db.commit() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="执行记录不存在") + + if history_row.case_result not in ("failed", "error"): + elapsed = (time.perf_counter() - t0) * 1000 + await _audit( + db, + operator=analyzer_employee_id, + action=action, + history_id=hid, + result_status="denied", + elapsed_ms=elapsed, + failure_category=req.failure_category, + session_id=_strip_or_empty(req.session_id) or None, + analysis_draft_id=_strip_or_empty(req.analysis_draft_id) or None, + version=_strip_or_empty(req.version) or None, + nonce=_strip_or_empty(req.nonce) or None, + message="非失败/异常记录", + ) + await db.commit() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="仅失败或异常记录允许一键入库", + ) + + case_name = history_row.case_name + failed_batch = history_row.start_time + platform = history_row.platform + if not case_name or failed_batch is None or platform is None: + elapsed = (time.perf_counter() - t0) * 1000 + await _audit( + db, + operator=analyzer_employee_id, + action=action, + history_id=hid, + result_status="failed", + elapsed_ms=elapsed, + failure_category=req.failure_category, + session_id=_strip_or_empty(req.session_id) or None, + analysis_draft_id=_strip_or_empty(req.analysis_draft_id) or None, + version=_strip_or_empty(req.version) or None, + nonce=_strip_or_empty(req.nonce) or None, + message="关键字段缺失", + ) + await db.commit() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="用例名/批次/平台缺失,无法写入失败原因", + ) + + prev_analyzed = history_row.analyzed or 0 + + is_bug = mapped_failed_type.strip().lower() == (bug_val or "").strip().lower() + try: + if is_bug: + owner_str = await _owner_for_bug(db, history_row) + else: + owner_str = await _owner_for_non_bug(db, mapped_failed_type) + except HTTPException as exc: + elapsed = (time.perf_counter() - t0) * 1000 + detail = exc.detail + msg = detail if isinstance(detail, str) else "跟踪人解析失败" + rs = "denied" if exc.status_code < 500 else "failed" + await _audit( + db, + operator=analyzer_employee_id, + action=action, + history_id=hid, + result_status=rs, + elapsed_ms=elapsed, + failure_category=req.failure_category, + session_id=_strip_or_empty(req.session_id) or None, + analysis_draft_id=_strip_or_empty(req.analysis_draft_id) or None, + version=_strip_or_empty(req.version) or None, + nonce=_strip_or_empty(req.nonce) or None, + message=msg, + ) + await db.commit() + raise exc + + pfr_stmt = ( + select(pfr) + .where( + pfr.case_name == case_name, + pfr.failed_batch == failed_batch, + pfr.platform == platform, + ) + .with_for_update() + ) + pfr_res = await db.execute(pfr_stmt) + existing = pfr_res.scalars().first() + + if existing: + if _pfr_matches( + existing, + failed_type=mapped_failed_type, + reason=reason_text, + owner=owner_str, + analyzer=analyzer_employee_id, + ): + history_row.analyzed = 1 + analyzed_updated = prev_analyzed != 1 + elapsed = (time.perf_counter() - t0) * 1000 + await _audit( + db, + operator=analyzer_employee_id, + action=action, + history_id=hid, + result_status="success", + elapsed_ms=elapsed, + failure_category=req.failure_category, + session_id=_strip_or_empty(req.session_id) or None, + analysis_draft_id=_strip_or_empty(req.analysis_draft_id) or None, + version=_strip_or_empty(req.version) or None, + nonce=_strip_or_empty(req.nonce) or None, + message="幂等:结论已一致,无需重复写入", + extra={"idempotent": True}, + ) + await db.commit() + return ApplyFailureReasonResponse( + history_id=hid, + applied=False, + analyzed_updated=analyzed_updated, + message="结论已一致,未重复写入", + ) + elapsed = (time.perf_counter() - t0) * 1000 + await _audit( + db, + operator=analyzer_employee_id, + action=action, + history_id=hid, + result_status="conflict", + elapsed_ms=elapsed, + failure_category=req.failure_category, + session_id=_strip_or_empty(req.session_id) or None, + analysis_draft_id=_strip_or_empty(req.analysis_draft_id) or None, + version=_strip_or_empty(req.version) or None, + nonce=_strip_or_empty(req.nonce) or None, + message="已存在人工/其他来源归因,拒绝覆盖", + extra={ + "existing_failed_type": existing.failed_type, + "existing_owner": existing.owner, + }, + ) + await db.commit() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="已存在失败归因记录;如需覆盖请使用「分析处理」人工确认", + ) + + db.add( + PipelineFailureReason( + case_name=case_name, + failed_batch=failed_batch, + platform=platform, + owner=owner_str, + reason=reason_text, + failed_type=mapped_failed_type, + analyzer=analyzer_employee_id, + ) + ) + history_row.analyzed = 1 + analyzed_updated = prev_analyzed != 1 + + elapsed = (time.perf_counter() - t0) * 1000 + await _audit( + db, + operator=analyzer_employee_id, + action=action, + history_id=hid, + result_status="success", + elapsed_ms=elapsed, + failure_category=req.failure_category, + session_id=_strip_or_empty(req.session_id) or None, + analysis_draft_id=_strip_or_empty(req.analysis_draft_id) or None, + version=_strip_or_empty(req.version) or None, + nonce=_strip_or_empty(req.nonce) or None, + message="写入成功", + ) + await db.commit() + logger.info( + "AI 一键入库成功 history_id=%s failed_type=%s analyzer=%s", + hid, + mapped_failed_type, + analyzer_employee_id, + ) + return ApplyFailureReasonResponse( + history_id=hid, + applied=True, + analyzed_updated=analyzed_updated, + message="已写入失败原因并标记为已分析", + ) + except HTTPException: + raise + except Exception: + logger.exception("AI 一键入库失败 history_id=%s", hid) + try: + await db.rollback() + except Exception: + logger.exception("回滚失败 history_id=%s", hid) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="写入失败,请稍后重试", + ) + + +async def reject_ai_failure_reason( + db: AsyncSession, + req: RejectFailureReasonRequest, + operator_employee_id: str, +) -> RejectFailureReasonResponse: + t0 = time.perf_counter() + action = "reject_failure_reason" + hid = req.history_id + elapsed = (time.perf_counter() - t0) * 1000 + await _audit( + db, + operator=operator_employee_id, + action=action, + history_id=hid, + result_status="success", + elapsed_ms=elapsed, + session_id=_strip_or_empty(req.session_id) or None, + analysis_draft_id=_strip_or_empty(req.analysis_draft_id) or None, + message=_strip_or_empty(req.reason) or "用户拒绝 AI 草稿", + ) + await db.commit() + return RejectFailureReasonResponse(history_id=hid, rejected=True, message="已拒绝本次分析草稿") diff --git a/backend/services/history_service.py b/backend/services/history_service.py index c043122..ac194cc 100644 --- a/backend/services/history_service.py +++ b/backend/services/history_service.py @@ -296,3 +296,41 @@ async def _distinct(column, desc=False, prefix=None): failure_owner=failure_owner, failed_type=failed_type, ) + + +async def list_recent_executions_by_case_platform( + db: AsyncSession, + case_name: Optional[str], + platform: Optional[str], + limit: int = 20, +) -> List[Dict[str, Optional[str]]]: + """ + 同 (case_name, platform) 近 N 条执行摘要,单表查询,按 start_time 降序。 + case_name 或 platform 为空时返回空列表(AI 上下文 builder 等调用方不阻塞)。 + """ + if ( + case_name is None + or not str(case_name).strip() + or platform is None + or not str(platform).strip() + ): + return [] + lim = max(1, min(int(limit), 100)) + stmt = ( + select(ph.start_time, ph.case_result, ph.code_branch) + .where(ph.case_name == case_name) + .where(ph.platform == platform) + .order_by(ph.start_time.desc()) + .limit(lim) + ) + result = await db.execute(stmt) + out: List[Dict[str, Optional[str]]] = [] + for r in result.all(): + out.append( + { + "start_time": r[0], + "case_result": r[1], + "code_branch": r[2], + } + ) + return out diff --git a/backend/tests/test_ai_context_builder.py b/backend/tests/test_ai_context_builder.py new file mode 100644 index 0000000..5fcec5d --- /dev/null +++ b/backend/tests/test_ai_context_builder.py @@ -0,0 +1,127 @@ +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from backend.models.pipeline_history import PipelineHistory +from backend.services import ai_context_builder +from backend.services.history_service import list_recent_executions_by_case_platform + + +def test_truncate_short_unchanged() -> None: + assert ai_context_builder._truncate("abc") == "abc" + assert ai_context_builder._truncate(None) is None + assert ai_context_builder._truncate(" ") is None + + +def test_truncate_long() -> None: + s = "x" * 5000 + out = ai_context_builder._truncate(s, max_chars=100) + assert out is not None + assert len(out) == 100 + assert out.endswith("...") + + +def test_parse_module_repo_mapping_missing_file(tmp_path) -> None: + p = str(tmp_path / "nope.yaml") + ai_context_builder._parse_module_repo_mapping_file.cache_clear() + assert ai_context_builder._parse_module_repo_mapping_file(p) == () + + +def test_parse_module_repo_mapping_valid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + y = tmp_path / "m.yaml" + y.write_text( + ( + "mappings:\n" + " - main_module: \"auth\"\n" + " repo_url: \"https://h.example/r\"\n" + " default_branch: \"main\"\n" + " path_hints: [\"a/\"]\n" + ), + encoding="utf-8", + ) + ai_context_builder._parse_module_repo_mapping_file.cache_clear() + t = ai_context_builder._parse_module_repo_mapping_file(str(y.resolve())) + assert len(t) == 1 + assert t[0].get("main_module") == "auth" + + +def test_repo_hint_for_main_module(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + y = tmp_path / "m.yaml" + y.write_text( + "mappings:\n - main_module: \"pay\"\n repo_url: \"https://h.example/pay\"\n default_branch: \"dev\"\n", + encoding="utf-8", + ) + monkeypatch.setattr( + "backend.services.ai_context_builder.settings.AI_MODULE_REPO_MAPPING_PATH", + str(y.resolve()), + ) + ai_context_builder._parse_module_repo_mapping_file.cache_clear() + h = ai_context_builder._repo_hint_for_main_module("pay") + assert h.get("repo_url") == "https://h.example/pay" + assert h.get("default_branch") == "dev" + ai_context_builder._parse_module_repo_mapping_file.cache_clear() + + +@pytest.mark.asyncio +async def test_build_analyze_payload_history_not_found() -> None: + r1 = MagicMock() + r1.scalar_one_or_none.return_value = None + mock_db = MagicMock() + mock_db.execute = AsyncMock(return_value=r1) + with pytest.raises(ai_context_builder.AIContextHistoryNotFoundError) as ei: + await ai_context_builder.build_analyze_payload(mock_db, 999) + assert ei.value.history_id == 999 + + +@pytest.mark.asyncio +async def test_build_analyze_payload_no_log_url(monkeypatch: pytest.MonkeyPatch) -> None: + row = MagicMock(spec=PipelineHistory) + row.id = 42 + row.start_time = "202604011200" + row.case_name = "case_login_fail" + row.platform = "Android" + row.main_module = "auth" + row.module = "auth" + row.subtask = "g1" + row.case_result = "failed" + row.code_branch = "master" + row.pipeline_url = "http://jenkins/p/1" + row.reports_url = "http://reports/batch/report/" + row.case_level = "P0" + row.screenshot_url = "http://img/s.png" + row.log_url = "http://logs/SECRET.html" # 不得进入 payload + + r1 = MagicMock() + r1.scalar_one_or_none.return_value = row + r2 = MagicMock() + r2.all.return_value = [ + ("202604011200", "failed", "master"), + ("202603011200", "passed", "master"), + ] + mock_db = MagicMock() + mock_db.execute = AsyncMock(side_effect=[r1, r2]) + + payload = await ai_context_builder.build_analyze_payload(mock_db, 42) + raw = json.dumps(payload, ensure_ascii=False) + assert "log_url" not in raw + assert "SECRET" not in raw + + assert payload["case_context"]["history_id"] == 42 + assert payload["case_context"]["batch"] == "202604011200" + assert payload["case_context"]["case_name"] == "case_login_fail" + assert payload["case_context"]["platform"] == "Android" + assert payload["case_context"]["screenshot_index_url"] == "http://img/s.png" + + assert isinstance(payload["recent_executions"], list) + assert len(payload["recent_executions"]) <= ai_context_builder.RECENT_EXECUTIONS_LIMIT + assert payload["repo_hint"] == {} + + +@pytest.mark.asyncio +async def test_list_recent_executions_empty_when_missing_names() -> None: + mock_db = MagicMock() + mock_db.execute = AsyncMock() + out = await list_recent_executions_by_case_platform(mock_db, None, "Android", 20) + assert out == [] + mock_db.execute.assert_not_called() diff --git a/backend/tests/test_ai_rate_limit.py b/backend/tests/test_ai_rate_limit.py new file mode 100644 index 0000000..51d67f0 --- /dev/null +++ b/backend/tests/test_ai_rate_limit.py @@ -0,0 +1,31 @@ +from backend.services.ai_rate_limit_service import HistoryAnalyzeRateLimiter + + +def test_history_rate_limit_threshold_and_window() -> None: + limiter = HistoryAnalyzeRateLimiter(window_seconds=60, max_requests=10) + now = 1000.0 + for _ in range(10): + allowed, count = limiter.try_acquire(history_id=1, now=now) + assert allowed is True + assert count <= 10 + + allowed, count = limiter.try_acquire(history_id=1, now=now) + assert allowed is False + assert count == 10 + + # 窗口滑出后恢复 + allowed, count = limiter.try_acquire(history_id=1, now=1061.0) + assert allowed is True + assert count == 1 + + +def test_history_rate_limit_isolated_by_history_id() -> None: + limiter = HistoryAnalyzeRateLimiter(window_seconds=60, max_requests=2) + now = 2000.0 + + assert limiter.try_acquire(history_id=100, now=now)[0] is True + assert limiter.try_acquire(history_id=100, now=now)[0] is True + assert limiter.try_acquire(history_id=100, now=now)[0] is False + + # 不同 history_id 不受影响 + assert limiter.try_acquire(history_id=200, now=now)[0] is True diff --git a/backend/utils/audit.py b/backend/utils/audit.py index 8063530..415c295 100644 --- a/backend/utils/audit.py +++ b/backend/utils/audit.py @@ -1,7 +1,10 @@ -from typing import Optional +import json +from typing import Any, Dict, Optional from sqlalchemy.ext.asyncio import AsyncSession +from backend.models.sys_audit_log import SysAuditLog + async def write_audit_log( db: AsyncSession, @@ -13,5 +16,19 @@ async def write_audit_log( detail: Optional[str] = None, ip_address: Optional[str] = None, ) -> None: - """写入审计日志到 sys_audit_log 表。占位实现。""" - pass + """写入审计日志到 sys_audit_log 表。""" + row = SysAuditLog( + operator=(operator or "").strip() or "unknown", + action=(action or "").strip() or "unknown", + target_type=target_type, + target_id=target_id, + detail=detail, + ip_address=ip_address, + ) + db.add(row) + await db.flush() + + +def build_audit_detail(payload: Dict[str, Any]) -> str: + """将审计详情序列化为 JSON 字符串。""" + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) diff --git a/config/module_repo_mapping.yaml.example b/config/module_repo_mapping.yaml.example new file mode 100644 index 0000000..d4eb0be --- /dev/null +++ b/config/module_repo_mapping.yaml.example @@ -0,0 +1,10 @@ +# AI 失败分析 — main_module → Code 仓库提示(复制为 module_repo_mapping.yaml 并配置 AI_MODULE_REPO_MAPPING_PATH) +# UTF-8。未命中时 repo_hint 为空对象,不报错。 + +mappings: + - main_module: "auth" + repo_url: "https://codehub.example.com/group/auth-service" + default_branch: "master" + path_hints: + - "src/auth/" + - "tests/auth/" diff --git a/docs/superpowers/specs/2026-04-08-ai-failure-analysis-architecture.md b/docs/superpowers/specs/2026-04-08-ai-failure-analysis-architecture.md index 0ed95ba..f87727b 100644 --- a/docs/superpowers/specs/2026-04-08-ai-failure-analysis-architecture.md +++ b/docs/superpowers/specs/2026-04-08-ai-failure-analysis-architecture.md @@ -1,16 +1,30 @@ # AI 辅助失败原因分析 — 架构设计 - **文档类型**:架构设计(Architecture Spec) -- **关联文档**:同目录下的 `2026-04-08-ai-failure-analysis-tech-selection.md`(技术选型) -- **状态**:Draft(待评审) +- **关联文档**: + - `2026-04-08-ai-failure-analysis-tech-selection.md`(技术选型) + - `2026-04-14-ai-failure-analysis-implementation-plan.md`(**实现计划与分期、穿刺范围**) +- **状态**:Draft(待评审,2026-04-14 已按产品讨论修订) - **作者**:AI 助手 × djn -- **日期**:2026-04-08 +- **日期**:2026-04-08(初稿);**修订**:2026-04-14(与测试/开发讨论结论收口) + +### 修订记录(2026-04-14) + +- **输出与落库**:明确「分析过程 + 完整结论」默认**不落业务库**;用户**一键确认**后,仅将 **失败归类** + **详细失败原因(结论)** 写入 `pipeline_failure_reason`(与「分析处理」「一键分析」对齐,见 §1.4、§9.4、§12.5)。 +- **失败归类**:`bug` / `spec_change` / `flaky` / `env`;其中 **规格变更**、**用例不稳定** 依赖与**最近一次成功批次**截图的对比;对比策略与降级见 §1.4、§8.3。 +- **历史成功数据**:由 **dt-report** 查得「最近一次成功批次」,在约定 URL 形态下**仅替换 batch** 生成成功侧 **截图 / 测试报告 HTML** 等资源入口(**不含日志 URL**);**AIFA 不连 MySQL**。 +- **截图与测试报告**:`screenshot_index_url`、`reports_url` 及成功侧对应 URL **通过契约传给 AIFA**,由 **AIFA 使用 httpx 直接拉取**(目录/索引页为 HTML 时可在 **AIFA 内**用 `selectolax` 等解析出子链后再拉图片,见 §8.3);**不由 dt-report 强制展开**为直链(若 dt-report 预展开为 `*_urls[]` 可作为可选优化,非必选)。 +- **限流**:**同一 `history_id` 在 1 分钟内最多触发 10 次**分析请求(§12.4);与模型能力无强绑定,主要为成本与防滥用。 +- **详细分析过程**:以 **结构化 evidence + 阶段时间线** 为主,**短论证摘要**为辅且限长;不默认暴露完整 CoT(§4.3、§9.4)。 +- **异步任务(二期)**:支持批量勾选、后台排队、进度、重试、取消;**单次 dt-report → AIFA 的 HTTP/SSE 调用超时 3 分钟**;每条任务独立结果入口(§9.6)。 +- **附加文件上传**:列为**二期 / 优化项**,当前版本不做(§16)。 +- **日志 vs 其它 URL**:契约 **不传 `log_url`**;Phase B 主证据来源为 **测试报告 HTML + 截图 URL**,由 **AIFA httpx 拉取**(§8.1、§8.3、§6.2)。 --- ## 0. 文档目的与适用范围 -本文档定义一个**新增的独立服务**——`ai-failure-analyzer`(下文简称 **AIFA**),以及它与既有 `dt-report` 系统之间的集成方式。目标是在不改动 `dt_infra` 数据库结构、不污染 `dt-report` 既有业务代码的前提下,为失败用例提供**AI 辅助的根因分析能力**。 +本文档定义一个**新增的独立服务**——`ai-failure-analyzer`(下文简称 **AIFA**),以及它与既有 `dt-report` 系统之间的集成方式。目标是在**不改动既有表结构红线**(见项目数据库规范)、**不污染** `dt-report` 既有业务逻辑的前提下,为失败用例提供**AI 辅助的根因分析能力**;分析**草稿**与过程默认不落业务库,**经用户确认**后可写入既有 `pipeline_failure_reason` 字段。 本文档**只描述架构**(组件边界、数据流、契约、状态机、错误策略、部署形态、安全与观测)。所有涉及"选哪个库、哪个模型、哪个协议"的决策放在技术选型文档。 @@ -24,24 +38,64 @@ - 需要一种能"读懂"日志/截图/代码历史并给出**初步归因结论**的能力。 ### 1.2 功能定位 -AIFA 是 **drill-down 级别** 的能力: -- **作用单位 = 单条失败用例**(不是整批) -- **作用页面 = 详细执行历史页的 Drawer**(与现有"失败归因"Tab 并列) +AIFA 是 **drill-down 级别** 的能力(**一期:单条**为主): +- **作用单位 = 单条失败用例**(一期入口:详细执行历史 Drawer;二期见 §9.6 批量排队) +- **作用页面 = 详细执行历史页的 Drawer**(与现有「失败归因」Tab 并列) - **作用角色 = 所有登录用户**(与现有 Drawer 权限一致) -- **产出 = 结构化的初步归因报告**(verdict / evidence / suspect patches / next steps) -- **落地方式 = 仅在前端侧边展示,零数据库写入** +- **产出**(见 §1.4):① **详细分析过程**(可观测、有依据);② **结论**(详细失败原因);③ **失败归类**;④ 可选 **一键写入** `pipeline_failure_reason`(仅 **失败归类** + **详细原因** 两个业务字段,过程本身不入库) +- **落地方式**:分析**草稿**在前端展示;**默认不写** `pipeline_failure_reason`;用户点击「一键设置到失败原因」后由 dt-report 写入(规则见 §1.4) ### 1.3 与既有"一键分析"的关系 | 维度 | 既有一键分析 | AIFA | |---|---|---| -| 粒度 | 整批 | 单条 | +| 粒度 | 整批 | 单条(一期);批量排队(二期 §9.6) | | 智能程度 | 0(纯规则) | LLM + 多数据源 | -| 写库 | 写 `pfr` + `ph.analyzed` | **零写库** | +| 写库 | 写 `pfr` + `ph.analyzed` | **分析过程与 AI 全文默认不入库**;用户确认后写 `pipeline_failure_reason`(`failed_type` + `reason` 等,§1.4) | | 部署 | dt-report 内嵌 | **独立服务** | | 目标 | 快速打标、让数据流转起来 | 辅助归因、缩短人工调查时间 | 两者**互补、不替代**。一键分析解决"怎么让整批失败进入流转",AIFA 解决"某一条失败到底是为什么"。 +### 1.4 输入、输出、失败归类与一键入库(产品口径) + +#### 1.4.1 输入(由 dt-report 拼入 payload,AIFA 不查 MySQL) + +前端发起分析时携带 **`history_id`**(及会话字段等)。**dt-report** 的 `ai_context_builder` 根据 `history_id` 读取 `pipeline_history` 等,至少拼出(字段名与表结构以实际实现为准,此处为业务语义): + +- **用例维度**:`batch`、`platform`、`module`(及业务需要的 `subtask` 等)、`case_name`、`code_branch`(供分析与业务展示;**不向 AIFA 传日志 HTML URL**) +- **资源入口(无日志 URL)**:`screenshot_index_url`(**截图目录/索引 URL**,见 §8.3)、`reports_url`(**测试报告 HTML 页面 URL**)等 —— **原样写入 payload**,由 **AIFA 经 httpx 拉取**(§8.1、§8.3),**dt-report 不传 `log_url`** +- **历史执行**:同 `(case_name, platform)` 近 N 次执行摘要(`recent_executions`) +- **最近一次成功批次**:由 dt-report 查询得到 `last_success_batch`;在团队约定下 **「同用例同形态 URL 仅 batch 段不同」**,对失败记录上的各资源 URL **仅替换 batch** 得到成功侧 **`success_screenshot_index_url`、成功侧 `reports_url`(若有)** 等(**不含日志 URL**);**以 URL 写入 payload**,由 **AIFA 直接拉取**(§8.3)。可选预填 `success_screenshot_urls[]` 非必选。 + +#### 1.4.2 输出(四层语义) + +1. **详细分析过程**(**不落库**):以 **结构化 `evidence[]`**(类型、来源、片段、引用)+ **阶段时间线**(Plan / 各 Skill / Synthesize 或等价阶段名 + 耗时)为主;可选 **短「论证摘要」**(与 evidence 编号互链,**字数上限**在实现中配置),**不默认**输出完整模型 CoT。 +2. **结论**:**详细失败原因**(长文本,展示用;**默认不落库**)。 +3. **失败归类**(枚举,**默认不落库**): + - **a. `bug`**:含崩溃、闪退等产品缺陷(与「环境问题」边界由测试用例约定,§1.4.3) + - **b. `spec_change`(规格变更)**:需 **对比** 当前失败截图(集)与 **历史成功**截图(集);若对比证据不足,**禁止强判**此类(见 §8.3 降级) + - **c. `flaky`(用例不稳定)**:需 **对比** 失败与 **历史成功**截图(集);证据不足时同上 + - **d. `env`(环境问题)** +4. **一键设置到失败原因**:用户确认后,dt-report 写入 **`pipeline_failure_reason`**(表名以 ORM 为准),**仅同步业务结论**: + - **`reason`**(或等价字段):采用 AI 返回的 **结论(详细失败原因)** + - **`failed_type`**(或等价字段):采用 AI 返回的 **失败归类**(需与库内既有枚举 **映射表** 对齐;无法映射时走默认或阻断并提示,实现阶段定表) + - **跟踪人 `owner`**(与现网「分析处理」「一键分析」一致): + - 若 AI 归类为 **`bug`**:`failed_type` 置为 bug 语义对应值;**跟踪人按模块**解析/落库(与现有一键分析链路对齐) + - 若为 **其他归类**:按 **分析处理** 中预设的「失败类型 → 跟踪人」关系设置 + - **覆盖策略**:若该 `(case_name, failed_batch, platform)` 已存在记录,是 **upsert** 还是 **二次确认**,产品需在实现前定稿(建议二次确认以防覆盖人工结论) + +**说明**:`pipeline_failure_reason` 为既有业务表;任何**新增列**须按项目规范走 `database/` SQL 迁移与 ORM 对齐;若仅写入已有列则不改表结构。 + +#### 1.4.3 失败归类边界(测试验收口径) + +- **`bug` 与 `env`**:例如仅客户端进程崩溃可归 `bug`;设备离线、测试桩不可达等可归 `env`——**细表由测试在验收用例中列举**,开发按同一表实现映射。 + +### 1.5 「用 history_id 后端拼 payload」的含义(给前端/测试) + +- 浏览器**不需要**自行拼凑截图目录 URL、近 N 次历史、成功批次等(**日志不通过 URL 传递**,见 §1.4.1)。 +- 前端只需在 Drawer 内发起 **`history_id`**(及 `session_id`、`mode` 等),**dt-report** 根据 `history_id` **只读**数据库与配置,组装 **AIFA 契约 JSON**,再带内部 token 转发 AIFA。 +- 好处:敏感拼装、与 `dt_infra` 的耦合集中在 dt-report;AIFA **零 MySQL**、不绑定表结构演进细节。 + --- ## 2. 核心设计原则 @@ -49,11 +103,12 @@ AIFA 是 **drill-down 级别** 的能力: 本架构的所有取舍都围绕以下原则展开,遇到冲突时优先级由上至下: 1. **与 dt_infra 数据库零耦合** —— AIFA 不连 MySQL,不知道表结构。 -2. **与 dt-report 代码零耦合** —— 调用通过 HTTP + 独立契约;dt-report 侧只增加两个薄文件。 +2. **与 dt-report 业务低耦合** —— AIFA 调用通过 HTTP + 独立契约;dt-report 侧以 **独立模块** 增加代理、payload 构造、(可选)一键入库与异步任务 API,**避免修改现有 service 核心逻辑**(见 §3.3)。 3. **降级优先于失败** —— 任何单一数据源故障返回 `partial` 报告,绝不整单崩。 -4. **生产级但足够简单** —— 一个 Agent、五个 Skill、五个 Tool、一个内存 Session Store;避免过度工程。 +4. **生产级但足够简单** —— 一个 Agent、五个 Skill、**四个 Tool**(截图 + **测试报告 HTML** + CodeHub×2)、一个内存 Session Store;避免过度工程。 5. **Token 成本硬约束** —— 结构化摘要在 Skill 之间流转,原始数据不透传到最终合成。 -6. **未来可替换** —— LLM 厂商、代码仓库实现、Session 后端、Mongo schema 都走抽象或配置,切换不改代码。 +6. **未来可替换** —— LLM 厂商、代码仓库实现、Session 后端都走抽象或配置,切换不改代码。 +7. **规格/不稳定类结论可证伪** —— 无成功截图对比证据时 **不输出强结论** 为 `spec_change` / `flaky`;写入 `data_gaps` 并降级(§1.4、§8.3)。 --- @@ -82,15 +137,13 @@ AIFA 是 **drill-down 级别** 的能力: │ ├── agent/orchestrator.py 单 Agent 三阶段主循环 │ │ ├── agent/skills/*.py 五个 skill 模块 │ │ ├── agent/prompts/*.md 每个 skill 的 system prompt │ -│ ├── tools/*.py 五个 tool │ -│ ├── clients/ mongo / http / codehub / llm │ +│ ├── tools/*.py 四个 tool │ +│ ├── clients/ http / codehub / llm │ │ ├── sessions/ 内存 LRU Session Store │ │ └── core/ config / logging │ │ │ │ ④ 外部数据源访问: │ -│ ├─► HTML log (httpx → log_url) │ -│ ├─► MongoDB (motor → 只读用户) │ -│ ├─► 截图 (httpx → screenshot_url, base64) │ +│ ├─► 截图/报告 (httpx → 契约 URL,HTML/图片;见 §8.1、§8.3) │ │ ├─► CodeHub (httpx → REST API + token) │ │ └─► LLM (httpx/openai-sdk → OpenAI 兼容端点) │ │ │ @@ -109,7 +162,7 @@ AIFA 是 **drill-down 级别** 的能力: | 环境变量前缀 | 现有 | **全部以 `AIFA_` 开头**,与 dt-report env 严格隔离 | | 日志目录 | 现有 `logs/` | 独立 `logs/aifa/` 或容器内 `/var/log/aifa` | | dt_infra 访问 | 读写(遵循现有红线) | **零访问**(不连 MySQL) | -| MongoDB 访问 | 无 | 只读账号 | +| 报告/截图源访问 | 无 | 通过 HTTP 可达 | | CodeHub 访问 | 无 | Service token | | LLM API 访问 | 无 | 独立 API key | | 生命周期 | 独立升级/重启 | 独立升级/重启 | @@ -118,22 +171,27 @@ AIFA 是 **drill-down 级别** 的能力: ### 3.3 dt-report 侧新增的最小改动 -**严禁改动任何现有 service**。只新增两个薄文件: +**一期目标**:尽量不改动现有 service 的核心逻辑;允许在评审后**增加**独立路由/服务模块(如「一键写入失败原因」「异步任务」)时**调用既有** `failure_process_service` / `one_click_analyze_service` 等中的**可复用片段**(以代码评审为准),避免复制粘贴业务规则。 + +**一期最少新增**(命名可微调): -1. **`backend/api/v1/ai_proxy.py`** —— 单接口 `POST /api/v1/ai/analyze` +1. **`backend/api/v1/ai_proxy.py`** —— `POST /api/v1/ai/analyze`(及追问同路径或子路径) - 复用 `get_current_user` 做 JWT 校验 + - **限流**:同一 **`history_id`** 在滑动/固定 **1 分钟窗口内最多 10 次**分析请求(§12.4);超限返回 **429** 与中文说明 - 调用 `ai_context_builder.build_payload(history_id)` 构造 payload - - `httpx.AsyncClient` 转发到 AIFA + - `httpx.AsyncClient` 转发到 AIFA,**单次调用读超时与 SSE 总时长上限 180s(3 分钟)**(与 §9.6 一致;实现可用分段超时) - 透明转发 AIFA 的 SSE 响应流 - - 写一条 `sys_audit_log`(顺便推动 audit 落地) - - 请求体:`{ history_id: int, follow_up_message?: str, session_id?: str, mode: "initial"|"follow_up" }` + - 写一条 `sys_audit_log`(见 §12.5) + - 请求体:`{ history_id: int, follow_up_message?: str, session_id?: str, mode: "initial"|"follow_up" }`(二期可加 `task_id` 等,§9.6) + +2. **`backend/services/ai_context_builder.py`** —— 构造 AIFA 的请求 payload(**纯数据读取**,不做 AI/Prompt) + - 按 `history_id` 读 `pipeline_history` 主记录,取出 **batch、platform、module、subtask、case_name、code_branch** 及 **截图目录、report** 等 URL 字段(**不向 AIFA 传日志 URL**) + - 复用 `history_service` 的 helper 查近 N 次相同 `(case_name, platform)` 的执行记录(默认 N=20)→ `recent_executions` + - **最近一次成功批次**:仅 dt-report 查库得到 `last_success_batch`;对失败 URL **仅替换 batch** 生成成功侧 **`success_screenshot_index_url`、成功侧报告 URL 等**写入 payload(**不含日志 URL**);**不在此步强制枚举子链**(可选优化见 §4.1) + - 读 `module_repo_mapping`(配置文件)得到 `repo_hint` + - 组装为 AIFA 契约 JSON -2. **`backend/services/ai_context_builder.py`** —— 构造 AIFA 的请求 payload - - 按 `history_id` 读 `pipeline_history` 主记录 - - 复用 `history_service` 的 helper 查近 N 次相同 `(case_name, platform)` 的执行记录(默认 N=20) - - 读 `module_repo_mapping`(配置文件,见 §4.3)得到 `repo_hint` - - 组装为 AIFA 契约定义的 JSON - - **纯数据读取**,不做任何 AI/Prompt 相关逻辑 +3. **(与用户确认动作配套)** `POST /api/v1/ai/apply-failure-reason`(命名待定)——将 AI 返回的 **失败归类 + 结论** 经映射后写入 `pipeline_failure_reason`,规则见 §1.4;须 **JWT + 权限 + 审计**;**禁止**在未登录或无权时写入。 ### 3.4 前端改动范围 @@ -153,6 +211,10 @@ AIFA 是 **drill-down 级别** 的能力: ### 4.1 Request Body +**约定**:**不向 AIFA 传递日志 HTML URL**(无 `log_url`字段);AIFA 主要使用 `reports_url` 与截图 URL 拉取证据(§8.1、§8.3)。 + +`screenshot_index_url` 为**截图目录或索引页 URL**(或单张 `image/*` 直链,由实现识别)。**默认由 AIFA** 使用 **httpx** 拉取;若为 HTML 索引页,在 **AIFA 内**解析出图片子链后再逐张拉取(`selectolax` 等,见技术选型 §6)。**dt-report 可选**预填 `screenshot_urls[]` / `success_screenshot_urls[]` 作为优化,**非必选**。`reports_url` 为测试报告 HTML,**由 AIFA** 调用 **`fetch_report_html`**(§6.2)拉取并截断。成功侧:`last_success_batch` + `success_screenshot_index_url`(及可选 `success_reports_url` / 与失败同字段名约定由实现固定)。 + ```json { "session_id": "uuid-generated-by-frontend", @@ -160,17 +222,23 @@ AIFA 是 **drill-down 级别** 的能力: "follow_up_message": "仅 mode=follow_up 时存在", "case_context": { "history_id": 123456, + "batch": "202604071200", "case_name": "test_login_with_invalid_password", "platform": "Android", "main_module": "auth", + "module": "auth", + "subtask": "可选", "start_time": "202604071930", "case_result": "failed", "code_branch": "master", - "log_url": "http://.../log/xxx.html", - "screenshot_url": "http://.../shot/xxx.png", + "screenshot_index_url": "http://.../batch_失败/screenshots/", + "screenshot_urls": ["http://.../a.png", "http://.../b.png"], "pipeline_url": "http://jenkins/.../123", - "reports_url": "http://.../report/xxx", - "case_level": "P0" + "reports_url": "http://.../batch_失败/report/", + "case_level": "P0", + "last_success_batch": "202604061200", + "success_screenshot_index_url": "http://.../batch_成功/screenshots/", + "success_screenshot_urls": ["http://.../ok1.png"] }, "recent_executions": [ { "start_time": "202604061930", "case_result": "passed", "code_branch": "master" }, @@ -184,8 +252,9 @@ AIFA 是 **drill-down 级别** 的能力: } ``` -- `recent_executions`:由 dt-report 按 `(case_name, platform)` 查近 N 条,AIFA 据此判断"首次失败 / 回归 / flaky"。 +- `recent_executions`:由 dt-report 按 `(case_name, platform)` 查近 N 条,AIFA 据此判断「首次失败 / 回归 / flaky」等;**与 `spec_change` / `flaky` 的视觉对比互补**(后者依赖成功截图集,§1.4)。 - `repo_hint`:**由 dt-report 侧维护**的 `main_module → 仓库` 映射(初期为 YAML 配置,后期可升级为字典表)。AIFA 不理解业务模块与仓库的对应关系。 +- **字段兼容**:若一期实现中暂不传 `module`/`subtask`/`last_success_batch` 等,以 `nullable`/缺省处理;**不得**要求 AIFA 访问 MySQL 补数据。 ### 4.2 SSE 响应事件 @@ -194,7 +263,7 @@ event: progress data: {"stage": "plan", "message": "规划分析路径..."} event: progress -data: {"stage": "log_analysis", "message": "正在分析日志..."} +data: {"stage": "report_analysis", "message": "正在分析报告与文本证据..."} event: progress data: {"stage": "code_blame", "message": "正在检索近期提交..."} @@ -211,20 +280,32 @@ data: {"error_code": "codehub_unauthorized", "message": "..."} ### 4.3 Response Schema(report 字段) +**与 §1.4 对齐**:`failure_category` 为产品枚举(`bug` | `spec_change` | `flaky` | `env`);`verdict` 可与之一致或作为对外的粗粒度兼容字段(实现阶段二选一或并存,须在 schema 中固定)。**详细分析过程**以 `evidence[]` + `stage_timeline[]` 为主;`rationale_summary` 为可选短摘要(**字数上限**)。 + +**`spec_change` / `flaky` 硬规则**:当 `success_screenshot_urls` 为空或对比证据不足时,模型**不得**将 `failure_category` 强判为 `spec_change` / `flaky`;应判为 `unknown` 或依赖日志/历史的次优结论,并在 `data_gaps` 写明原因。 + ```json { "session_id": "uuid", "status": "ok | partial | error", "report": { + "failure_category": "bug | spec_change | flaky | env | unknown", "verdict": "product_bug | env_issue | test_flaky | infra | unknown", "confidence": 0.0, "summary": "一句话结论", + "detailed_reason": "详细失败原因(长文本,供展示与一键入库 reason)", + "rationale_summary": "短论证摘要,与 evidence id 互链;可空", + "stage_timeline": [ + { "stage": "plan", "message": "规划分析路径", "elapsed_ms": 1200 }, + { "stage": "report_analysis", "message": "分析报告文本证据", "elapsed_ms": 8000 } + ], "evidence": [ { - "type": "log_excerpt | screenshot_observation | commit | history_pattern", - "source": "mongo_log | html_log | screenshot | codehub | recent_executions", + "id": "e1", + "type": "log_excerpt | report_excerpt | screenshot_observation | screenshot_compare | commit | history_pattern", + "source": "report_html | screenshot | codehub | recent_executions", "snippet": "...", - "reference": "具体指向(日志行号/commit sha/历史批次)" + "reference": "具体指向(日志行号/commit sha/历史批次/截图序号)" } ], "suspect_patches": [ @@ -238,10 +319,10 @@ data: {"error_code": "codehub_unauthorized", "message": "..."} } ], "suggested_next_steps": ["...", "..."], - "data_gaps": ["Mongo 未检索到该批次日志", "..."] + "data_gaps": ["成功侧截图索引解析失败,未做规格/不稳定对比", "..."] }, "trace": { - "skills_invoked": ["log_analysis", "code_blame", "synthesis"], + "skills_invoked": ["report_analysis", "code_blame", "synthesis"], "tool_calls": 7, "llm_input_tokens": 12034, "llm_output_tokens": 1820, @@ -305,37 +386,29 @@ data: {"error_code": "codehub_unauthorized", "message": "..."} | Skill | 目的 | 允许调用的 Tool | 产出结构化字段 | |---|---|---|---| | `history_skill` | 判断**偶发/回归/新失败**,看历史通过/失败模式 | _(无 tool,仅读 payload.recent_executions)_ | `pattern`(flaky/regression/new/persistent)、`last_pass_batch` | -| `log_analysis_skill` | 定位根因行、堆栈、异常关键字 | `fetch_log_html`, `query_mongo_logs` | `error_lines[]`, `stack_summary`, `keywords[]` | -| `screenshot_skill` | 识别截图中的 UI 状态(报错弹窗/空白页/toast 等) | `fetch_screenshot_b64` | `ui_state`, `visible_error_text`, `description` | +| `report_analysis_skill` | 定位根因行、堆栈、异常关键字:**测试报告 HTML**(契约 `reports_url`,AIFA 拉取) | `fetch_report_html` | `error_lines[]`, `stack_summary`, `keywords[]`, `report_excerpt`(结构化) | +| `screenshot_skill` | 从 **`screenshot_index_url` / `success_screenshot_index_url`**(及可选预填直链表)拉取图片;识别 UI;有成功集时 **LLM 多图对比**(§4.3 硬规则) | `fetch_screenshot_b64`(**支持直链或索引页 URL**,内部可解析 HTML 后再多次 GET) | `ui_state`, `visible_error_text`, `description`, `compare_notes[]` | | `code_blame_skill` | 反推可能引入问题的 patch | `codehub_list_commits`, `codehub_get_commit_diff` | `suspect_patches[]`(sha/author/why_suspect) | | `synthesis_skill` | 汇总成最终报告 | _(无 tool,输入各 skill 摘要)_ | 完整 `report` 对象 | -### 6.2 Tool 清单(5 个) +### 6.2 Tool 清单(4 个) -所有 Tool 都是 `async` Python 函数,通过 OpenAI function-calling 协议暴露给 LLM。 +所有 Tool 都是 `async` Python 函数,通过 OpenAI function-calling 协议暴露给 LLM。**不提供**「按 **日志** HTML URL 抓取」Tool(契约不传 `log_url`)。**截图、测试报告** 通过契约中的 **URL 由 AIFA 拉取**。 ```python -# 1. HTML 日志抓取 -async def fetch_log_html(log_url: str, max_chars: int = 20000) -> dict: - """httpx GET → selectolax 提正文 → 去时间戳/ANSI → 截断""" - # returns: {text, truncated, content_length} - -# 2. MongoDB 结构化日志查询 -async def query_mongo_logs( - case_name: str, batch: str, platform: str, - levels: list[str] = ["ERROR", "WARN"], limit: int = 200 -) -> dict: - """motor 只读查询,按 level 过滤,按 timestamp 倒序""" - # returns: {records: [...], total} +# 1. 测试报告 HTML(契约 reports_url) +async def fetch_report_html(reports_url: str, max_chars: int = 20000) -> dict: + """httpx GET → selectolax 提正文或关键区域 → 截断;非 HTML 或失败返回结构化 error""" + # returns: {text, truncated, content_length} 或 {error, detail} -# 3. 截图获取 +# 2. 截图:直链 image/* 或索引页 URL(索引页需解析后再逐张拉取) async def fetch_screenshot_b64( screenshot_url: str, max_bytes: int = 2_000_000 ) -> dict: - """httpx GET → 校验 content-type/size → base64 编码""" - # returns: {base64, mime, size_bytes, truncated} + """httpx GET;单张图 base64。若 URL 为目录索引 HTML,由 skill 内先解析出子 URL 再循环调用本 tool""" + # returns: {base64, mime, size_bytes, truncated} 或 {error, detail} -# 4. CodeHub 提交列表 +# 3. CodeHub 提交列表 async def codehub_list_commits( repo_url: str, branch: str, since: str, until: str, path_filters: list[str] | None = None, limit: int = 30 @@ -343,7 +416,7 @@ async def codehub_list_commits( """CodeHub REST API,时间窗 + 路径过滤""" # returns: {commits: [{sha, author, time, message, files}]} -# 5. CodeHub 单 commit diff +# 4. CodeHub 单 commit diff async def codehub_get_commit_diff( repo_url: str, sha: str, max_lines: int = 500 ) -> dict: @@ -391,75 +464,50 @@ class SessionState: ## 8. 外部数据源集成与降级 -### 8.1 HTML 日志 +### 8.1 文本证据来源(无日志 URL) -| 维度 | 细节 | -|---|---| -| 客户端 | `httpx.AsyncClient`(共享连接池) | -| 解析 | `selectolax` 提 `` 纯文本 | -| 超时 | connect 3s / read 10s | -| 并发 | 全局 semaphore ≤ 4 在途 | -| 后处理 | 去时间戳前缀、ANSI 色码;按行 split 保留末 N 行(默认 800) | -| 截断阈值 | `max_chars=20000` | +**本期不向 AIFA 传入日志 HTML URL**,也不依赖独立 Mongo 数据源。失败用例文本证据来自 `reports_url` 的 HTML 抽取;若报告不可用,则仅依赖截图、历史与代码证据并在 `data_gaps` 标注。 -**降级**: -- 连不上 → skill 产出空 `error_lines=[]`,`data_gaps` 记"HTML 日志获取失败" -- 解析不出正文 → 返回原始文本末尾 2000 字符 + WARNING -- 超大 → 截断继续 + `data_gaps` 记 +### 8.3 截图与测试报告 HTML(契约 URL,AIFA 直连) -### 8.2 MongoDB 结构化日志 +**业务语义**:`pipeline_history` 侧存的是 **截图目录/索引页 URL** 或 **单张直链**;`reports_url` 为 **测试报告 HTML 页面 URL**。二者均经 **payload 传给 AIFA**,由 **AIFA 使用 httpx 拉取**(与「不传日志 URL」一致)。 | 维度 | 细节 | |---|---| -| 驱动 | `motor` | -| 连接 | 启动时建立单例 `AsyncIOMotorClient`,连接池 10 | -| 账号 | **只读用户**,env `AIFA_MONGO_URI` | -| 超时 | `serverSelectionTimeoutMS=3000`, `socketTimeoutMS=8000` | -| 查询 | 只用 `find`,不用 `aggregate`/`mapReduce` | -| 字段名 | **全部走 env 配置**,避免硬编码(AIFA 不控制 Mongo schema) | - -所需 env: -``` -AIFA_MONGO_URI -AIFA_MONGO_DB -AIFA_MONGO_LOG_COLLECTION -AIFA_MONGO_FIELD_CASE_NAME -AIFA_MONGO_FIELD_BATCH -AIFA_MONGO_FIELD_PLATFORM -AIFA_MONGO_FIELD_LEVEL -AIFA_MONGO_FIELD_TIMESTAMP -``` - -**降级**: -- Mongo 连不上 → skill 跳过 Mongo 分支,仅用 HTML,`data_gaps` 记 -- Mongo 查空 → 不是错误,正常返回 -- Mongo 超时 → 降级到 HTML + WARNING - -### 8.3 截图 +| **拉取责任** | **默认在 AIFA**:对 `screenshot_index_url` / `success_screenshot_index_url` 先 **GET**;若响应为 **`image/*`** 则按单张处理;若为 **`text/html`** 则在 **AIFA 内**用 `selectolax`(或等价)解析出图片直链列表,再逐张 `GET`(**解析规则与现网索引页结构绑定**,单测覆盖)。**dt-report** 若已预填 `screenshot_urls[]` / `success_screenshot_urls[]`,AIFA **可优先使用**以省一次索引请求。 | +| **测试报告** | `reports_url` 由 **`fetch_report_html`**(§6.2)拉取 HTML → 提正文或关键片段 → **截断**后进入 `report_analysis_skill` 摘要或独立证据字段(实现阶段固定 schema)。 | +| 客户端 | AIFA 共用 `httpx.AsyncClient` | +| 超时 | 索引页 / 单图 connect 3s / read 8s(可配置);报告 HTML read 10s(可配置) | +| 大小硬上限 | **单张图 2MB**;**报告 HTML** `max_chars`(如 20000)超限截断 + `data_gaps` | +| content-type | 图片必须以 `image/` 开头;报告为 `text/html` 或容错 | +| **张数上限** | 解析出的图片各自最多 **N 张**(建议 ≤10,可 env);超出则取「前 N-1 + 最后 1 张」等策略 | +| 编码 | 图片 base64 送视觉模型;多图时分段受模型限制 | -| 维度 | 细节 | -|---|---| -| 客户端 | 共用 `httpx.AsyncClient` | -| 超时 | connect 3s / read 8s | -| 大小硬上限 | **2MB**(超过直接 error) | -| content-type 校验 | 必须以 `image/` 开头 | -| 编码 | base64(data URL)送给视觉模型 | +**视觉模型调用**(由 `screenshot_skill` 发起,示意多图): -**视觉模型调用**(由 `screenshot_skill` 发起): ```python -messages = [ - {"role": "system", "content": prompt}, - {"role": "user", "content": [ - {"type": "text", "text": "这是该用例失败时的截图..."}, - {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}} - ]} -] +content = [{"type": "text", "text": "以下为失败执行截图(按执行顺序)..."}] +for i, b64 in enumerate(failure_images): + content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) +if success_images: + content.append({"type": "text", "text": "以下为最近一次成功批次截图,请对比 UI 差异..."}) + for b64 in success_images: + content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) +messages = [{"role": "system", "content": prompt}, {"role": "user", "content": content}] ``` +**规格变更 / 用例不稳定 对比策略(推荐一期)**: + +- **主路径**:**LLM 多图视觉对比** + **§4.3 硬规则**(无成功集则不强判 `spec_change`/`flaky`)。 +- **可选演进**:在对比前增加轻量确定性预处理(缩略图、简单差异分),作为 gate 或辅助输入——**非一期必选**。 + **降级**: -- 图片拉取失败 → skill 产出 `{ui_state: "unknown"}` -- 图片过大 → error + `data_gaps` 记 -- 视觉模型调用失败(配额/网络)→ 整个 skill 降级返回空 + WARNING + +- 索引页无法解析、无子链或全空 → `data_gaps` 记录;**不整单失败** +- 单张拉取失败 → 跳过该张,继续其他张 +- `reports_url` 拉取失败或解析为空 → `data_gaps`;其余 skill 继续 +- **成功侧**全部不可用 → **禁止**输出 `spec_change`/`flaky` 强结论(§4.3),其余 skill 仍可按日志/历史输出 `bug`/`env`/`unknown` +- 视觉模型调用失败(配额/网络)→ skill 降级 + WARNING + `data_gaps` ### 8.4 CodeHub @@ -545,14 +593,21 @@ error - 追问:`mode=follow_up, session_id, follow_up_message` - Drawer 关闭 → session_id 作废(前端 state 销毁) -### 9.4 报告渲染 +### 9.4 报告渲染与一键入库 `ReportView.tsx` 按结构化 schema 渲染(非裸 markdown): -- **顶部大卡片**:`verdict` + `confidence` + `summary` -- **中部三列/折叠区**:`evidence`(按 source 分组)、`suspect_patches`(表格)、`suggested_next_steps`(列表) + +- **顶部大卡片**:`failure_category`(或 `verdict`)+ `confidence` + `summary` +- **结论区**:`detailed_reason`(可折叠长文) +- **详细分析过程(不落库)**: + - **阶段时间线** `stage_timeline[]`(步骤名 + 文案 + 耗时) + - **`evidence[]`**:按 `source` / `type` 分组,**`snippet` 默认折叠**,支持展开;若有 `id` 可与 `rationale_summary` 互链 +- **中部**:`suspect_patches`(表格)、`suggested_next_steps`(列表) - **底部警示条**:`data_gaps`(灰色提示) -- **角标**:`trace.tool_calls / elapsed_ms`(方便解释成本和调试) -- **原始 evidence snippet 允许展开**,默认折叠 +- **角标**:`trace.tool_calls / elapsed_ms`(成本与调试) +- **主操作**:**「一键设置到失败原因」**按钮 —— 调用 dt-report 接口 **POST /api/v1/ai/apply-failure-reason**(命名待定),请求体携带 `history_id`、`failure_category`、`detailed_reason`(及防重放/版本戳等实现自定);服务端按 §1.4 映射写入 **`pipeline_failure_reason`**,成功后 Toast;失败返回明确中文原因 + +**刷新与关闭 Drawer**:未入库前,分析结果仍为**会话级**;关闭 Drawer 后是否保留前端 state 由产品决定(默认不保留,与旧 §9.3 session 约定一致)。**入库后**以列表/详情读 `pipeline_failure_reason` 为准。 ### 9.5 Tab 懒加载 @@ -560,6 +615,33 @@ error - 首次 mount 不自动发请求;必须用户点"开始分析"按钮才发 - 这让"好奇点开 Drawer 但不想分析"的用户不产生任何 LLM 成本 +### 9.6 批量勾选、后台排队与进度(二期 / 优化项) + +**目标**:用户在列表中**勾选多条**失败用例,触发 **后台分析队列**;前端展示**排队位置 / 进行中 / 已完成**,每条有**独立入口**查看报告(与单条 Drawer 内体验对齐)。 + +**任务模型(概念 schema)**: + +| 字段 | 说明 | +|---|---| +| `task_id` | UUID,全局唯一 | +| `history_id` | 对应一条失败执行 | +| `status` | `queued` \| `running` \| `succeeded` \| `failed` \| `cancelled` \| `partial`(与报告 status 可区分命名,实现自定) | +| `progress` | 0–100 或阶段枚举 + 文案 | +| `attempt` | 重试次数 | +| `error_code` / `message` | 失败时 | +| `created_at` / `started_at` / `finished_at` | 时间戳 | + +**能力**: + +- **重试**:用户对 `failed` 任务手动重试(**是否计入** `history_id` 的 10 次/分钟 额度,建议 **计入**,防刷) +- **取消**:**至少**允许取消 `queued`;`running` 是否支持协作取消依赖 AIFA 是否实现中断信号(二期评审) +- **超时**:**单次** dt-report → AIFA 的 HTTP/SSE 客户端 **总等待 3 分钟**;超时将该任务标为 `failed` 或 `partial`(若中途已有部分 SSE 数据,由实现定义),**不**默认无限挂起 +- **并发**:仍受 AIFA `AIFA_MAX_CONCURRENT_ANALYSES` 与 dt-report 队列 worker 数约束 + +**持久化**:队列状态若需跨进程/刷新可恢复,须 **独立存储**(Redis 或 DB 任务表);**新建表**须按项目规范提交 SQL 迁移。若一期不做持久化,则队列仅 **单进程内存**、刷新即失,须在 UI 明示。 + +**与一期关系**:一期可仅实现单条 Drawer + SSE;二期再挂接同一 AIFA 契约与同一报告 schema。 + --- ## 10. 错误分级与响应 @@ -584,13 +666,13 @@ error { "status": "ok", "checks": { - "mongo": "ok", + "report_source": "ok", "codehub": "ok", "llm": "ok" } } ``` -- **Mongo**:`ismaster` ping +- **报告/截图源**:对关键 URL 做轻量连通性探测或启动期校验 - **CodeHub**:轻量 API 调用(如获取 token info) - **LLM**:**不在健康检查里调**(太贵),改为**启动时一次 warmup** @@ -676,9 +758,6 @@ screenshot_hash=sha256:zzzzzzz bytes=102400 | `AIFA_LLM_BASE_URL` | OpenAI 兼容端点 | | `AIFA_LLM_TEXT_MODEL` | 文本推理模型名 | | `AIFA_LLM_VISION_MODEL` | 视觉模型名 | -| `AIFA_MONGO_URI` | Mongo 只读连接串 | -| `AIFA_MONGO_DB` / `AIFA_MONGO_LOG_COLLECTION` | 库/集合 | -| `AIFA_MONGO_FIELD_*` | 字段映射(见 §8.2) | | `AIFA_CODEHUB_BASE_URL` | CodeHub API 根 | | `AIFA_CODEHUB_TOKEN` | CodeHub service token | | `AIFA_CODE_REPO_PROVIDER` | 代码仓库实现(`codehub`) | @@ -708,20 +787,21 @@ screenshot_hash=sha256:zzzzzzz bytes=102400 ### 12.4 速率限制 -- **dt-report 侧**(在 `ai_proxy` 加):同一用户每分钟 ≤10 次、每小时 ≤50 次 → 触顶返回 429 +- **dt-report 侧(硬指标,与产品对齐)**:**同一 `history_id` 在 1 分钟内最多触发 10 次**「发起分析」请求(含用户快速重试;**自动重试是否计入**须在实现中固定并写入运维说明)。超限返回 **429**,body 中文说明。 +- **补充(可选全局护栏)**:同一用户每分钟 / 每小时总次数上限(如原 10/min、50/h)可作为**额外**防护,**不得**弱于单 `history_id` 限制。 - **AIFA 侧**:全局并发 semaphore,默认 `AIFA_MAX_CONCURRENT_ANALYSES=8`;超过直接返回 503(不排队,避免 SSE 超时体验变差) -### 12.5 审计 +### 12.5 审计与业务写库边界 -- dt-report 侧 `ai_proxy` 每次调用写一条 `sys_audit_log`(顺便推动 audit 落地) - - 字段:`user_employee_id / history_id / session_id / mode / result_status` - - **这是唯一对 dt_infra 的写入**,符合"AIFA 不碰 dt_infra"红线 -- AIFA 侧只写自己的 `trace.log`,不碰 dt_infra +- **AIFA**:不连接 MySQL;仅写自身 `trace.log` / 应用日志。 +- **dt-report `ai_proxy`**:每次调用 AIFA(含追问)写 **`sys_audit_log`**(若该表已落地),建议字段:`user_employee_id / history_id / session_id / mode / result_status`(`result_status` 取报告 `status` 或 HTTP 摘要)。 +- **dt-report `apply-failure-reason`(一键入库)**:用户显式确认后,写入 **`pipeline_failure_reason`**(`failed_type`、`reason`、`owner`、`analyzer` 等按 §1.4 映射),**必须**同时写审计(同上或独立 action 类型),且 **JWT + 权限校验**。 +- **红线重申**:**禁止** `pipeline_overview` / `pipeline_history` **DELETE**;**禁止** ORM 自动建表;其它表的 **INSERT/UPDATE** 须符合项目迁移与业务红线。一键入库**不得**在未经用户点击时静默写入。 ### 12.6 输入净化 - `follow_up_message`:最大 2000 字符 -- Mongo 查询参数:显式 escape(尽管 motor 参数化已基本免疫) +- 对 `reports_url` / `screenshot_url` 做域名白名单与长度校验,避免 SSRF 风险 - CodeHub URL:拼接前用白名单校验域名(必须匹配 `AIFA_CODEHUB_BASE_URL` 的域) --- @@ -754,7 +834,6 @@ services: - AIFA_PORT=8080 - AIFA_LLM_API_KEY=${AIFA_LLM_API_KEY} - AIFA_LLM_BASE_URL=${AIFA_LLM_BASE_URL} - - AIFA_MONGO_URI=${AIFA_MONGO_URI} - AIFA_CODEHUB_BASE_URL=${AIFA_CODEHUB_BASE_URL} - AIFA_CODEHUB_TOKEN=${AIFA_CODEHUB_TOKEN} - AIFA_INTERNAL_TOKEN=${AIFA_INTERNAL_TOKEN} @@ -795,38 +874,46 @@ services: |---|---|---| | ADR-01 | 独立服务而非 dt-report 内嵌模块 | 用户明确要求"解耦到独立进程/容器";秘钥/成本/发布彻底隔离 | | ADR-02 | AIFA 零访问 dt_infra,由 dt-report 推送 payload | 与数据库红线一致;AIFA 不绑定 dt_infra schema | -| ADR-03 | 单 Agent + 5 Skill + 5 Tool | 多 Agent 是过度工程;单 Agent 三阶段状态机已足够 | +| ADR-03 | 单 Agent + 5 Skill + **4 Tool**(报告 HTML + 截图 + CodeHub×2) | 不传**日志** URL;截图/报告 URL 由 AIFA 拉取 | | ADR-04 | Agent 三阶段 Plan → Act → Synthesize,Skill 之间只传结构化摘要 | 防 token 爆炸;可预测可观测 | | ADR-05 | 前端 SSE 流式而非轮询 | 用户体验更好,代码增量很小 | | ADR-06 | 交互 = 一次性为主 + 轻量追问 | 用户明确选择;追问复用 session 摘要,不重跑 tool | -| ADR-07 | 结果零数据库写入,只在侧边 Drawer 展示 | 用户明确选择;避免与现有写入流程冲突 | -| ADR-08 | 仅 5 个 Tool,严格 async/timeout/截断/结构化错误/幂等/审计 | 生产级最小集合;足够支撑当前需求 | +| ADR-07 | **分析草稿**(过程 + 完整结论)**默认不落业务库**;用户**一键确认**后仅将 **失败归类 + 详细原因** 写入 `pipeline_failure_reason`,并与「分析处理」「一键分析」owner 规则对齐(§1.4) | 兼顾可审计与人工确认;避免静默覆盖 | +| ADR-08 | 仅 **4** 个 Tool,严格 async/timeout/截断/结构化错误/幂等/审计;**无日志 URL 抓取**;**报告/截图走契约 URL** | 生产级最小集合 | | ADR-09 | LLM 走 OpenAI 兼容协议,初期 GLM | 切换厂商零代码改动;初期最小配置 | -| ADR-10 | Mongo 字段名全部走 env 配置 | AIFA 不控制 Mongo schema;换源只改 env | +| ADR-10 | 证据拉取参数全部走配置 | 便于按环境切换报告/截图源策略,避免硬编码 | | ADR-11 | CodeHub 初期唯一实现,同时保留 `CodeRepoClient` Protocol | 生产级与简单的平衡;抽象成本可忽略 | | ADR-12 | Session 存内存 LRU;抽象为 Protocol 便于未来换 Redis | 初期单实例够用,无需 Redis 依赖 | | ADR-13 | 内部 service token 而非转发 JWT | 避免跨系统 token 语义污染 | | ADR-14 | AIFA 只绑内网,浏览器不直连 | 鉴权/限流/审计集中在 dt-report 一处 | -| ADR-15 | dt-report 侧唯一改动 = ai_proxy + ai_context_builder 两个文件 | 不污染现有 service,顺便推动 sys_audit_log 落地 | +| ADR-15 | dt-report 侧以 **独立模块** 增加 `ai_proxy`、`ai_context_builder`、**一键入库 API**;**避免修改现有 service 核心逻辑**,可复用其函数 | 降低回归面;入库与分析与现网规则一致 | | ADR-16 | 单请求 token 硬上限 + 按天成本聚合 | 防止单 bug 烧光一天配额 | | ADR-17 | Partial 优先于整单失败 | 降级优于失败,提高整体可用性 | | ADR-18 | Prompt 作为代码走 git,不做运行时热更新 | 便于 review 和回滚 | | ADR-19 | 前端 AI 组件独立目录,不污染 HistoryPage.tsx | HistoryPage.tsx 已 2010 行,继续塞会拖慢编辑和渲染 | | ADR-20 | Tab 懒加载,首次 mount 不自动发请求 | 避免好奇用户产生无意义 LLM 成本 | +| ADR-21 | **截图/报告 URL** 由契约传入,**默认 AIFA httpx 拉取**;索引页在 **AIFA 内**解析;dt-report **可**预填直链作优化;张数/大小硬上限 | 与「AIFA 直连资源」一致;可选预展开减负 | +| ADR-22 | **最近一次成功批次** 仅 dt-report 查询;URL **仅替换 batch**;失败降级见 §8.3 | AIFA 零 MySQL;与现网 URL 约定绑定 | +| ADR-23 | **`spec_change`/`flaky` 证据不足不强判** | 防止无成功截图时模型胡判 | +| ADR-24 | **单 `history_id` 10 次/分钟** 限流在 dt-report | 成本与防滥用 | +| ADR-25 | **详细过程 = evidence + 阶段时间线**,短 `rationale_summary` 可选;不默认暴露完整 CoT | 可验收、可脱敏 | +| ADR-26 | **二期** 批量后台队列 + 每任务 3min 超时 + 重试/取消(§9.6) | 与一期单条解耦交付 | +| ADR-27 | **附加文件上传** 二期再做 | 降低一期范围 | --- ## 16. 未来演进方向 -列为未纳入当前版本范围的方向,供后续迭代参考: +列为**未纳入一期**或**可持续优化**的方向(部分已在 §9.6、§1.4 有雏形): -1. **整批分析**:从单用例扩展到整批失败的聚类归因 -2. **历史 AI 报告沉淀**:将高质量报告经人工确认后写入 `pipeline_failure_reason.reason`,形成闭环(需产品层面决策,会打破 ADR-07) -3. **多厂商灰度**:通过 A/B 路由在 GLM/Kimi/MiniMax 之间对比质量 -4. **Redis session**:多副本部署时替换 `SessionStore` 实现 -5. **Prometheus 指标**:接统一监控平台 -6. **离线评估集**:收集人工标注的失败样本作为 AIFA 质量回归测试 -7. **Fine-tune / RAG**:将项目特有的错误模式沉淀为知识库 +1. **批量队列 UI 与持久化**:多选、后台 worker、跨刷新恢复(可能引入 Redis 或任务表 + 迁移) +2. **多厂商灰度**:通过 A/B 路由在 GLM/Kimi/MiniMax 之间对比质量 +3. **Redis session**:多副本部署时替换 `SessionStore` 实现 +4. **Prometheus 指标**:接统一监控平台 +5. **离线评估集**:收集人工标注的失败样本作为 AIFA 质量回归测试 +6. **Fine-tune / RAG**:将项目特有的错误模式沉淀为知识库 +7. **截图对比增强**:轻量图像相似度/关键区域裁剪后再送视觉模型(非一期必选) +8. **附加文件上传**:分析请求附加用户文件(安全扫描、大小类型限制、独立存储设计) --- @@ -835,25 +922,32 @@ services: - [ ] AIFA 进程完全独立,不 import 任何 `backend.*` 模块 - [ ] AIFA 所有 env 以 `AIFA_` 开头 - [ ] AIFA 不建立任何 MySQL 连接 -- [ ] 所有 5 个 tool 都是 async,都有 timeout 和截断 -- [ ] 所有 5 个 tool 返回结构化错误而非 raise +- [ ] 所有 **4** 个 tool 都是 async,都有 timeout 和截断 +- [ ] 所有 **4** 个 tool 返回结构化错误而非 raise +- [ ] 契约 **无 `log_url`**;文本证据主路径为 `reports_url` - [ ] Plan 阶段输出严格受 JSON schema 约束 - [ ] Skill 之间只传结构化摘要,不传原始 tool output - [ ] Synthesize 阶段 prompt 输入长度有硬上限 - [ ] 单请求累计 tokens 超过 `AIFA_MAX_TOKENS_PER_REQUEST` 自动熔断为 partial -- [ ] 外部数据源故障返回 partial 而非 500 +- [ ] 外部数据源故障返回 partial 而非 500(**配置类** fail-loud 除外) - [ ] CodeHub 401 是 fail-loud(返回 500) - [ ] LLM API key 无效是 fail-loud -- [ ] dt-report 侧只新增 `ai_proxy.py` 和 `ai_context_builder.py` 两个文件,不改现有 service +- [ ] dt-report:`ai_proxy`、`ai_context_builder`、**一键入库 API** 职责清晰;**尽量不修改**现有 service 核心逻辑 +- [ ] **单 `history_id` 10 次/分钟** 限流生效,返回 429 +- [ ] **截图/报告 URL**:AIFA 拉取、索引页解析、张数/大小上限有文档与单测;可选 dt-report 预填直链 +- [ ] **`spec_change`/`flaky`** 在成功截图证据不足时**不强判**(契约或后处理校验) +- [ ] 报告含 `stage_timeline`、`evidence`(可选 `id`)、`detailed_reason`、`failure_category` +- [ ] 一键入库仅写 **`pipeline_failure_reason`** 约定字段,**须用户点击**,写审计 - [ ] `HistoryPage.tsx` 零改动或仅改一行挂 Tab - [ ] 所有 AI 前端组件在独立目录 `ai_analysis/` - [ ] Tab 懒加载 -- [ ] 前端 session_id 由浏览器生成,Drawer 关闭即作废 +- [ ] 前端 session_id 由浏览器生成,Drawer 关闭即作废(与入库后读库展示区分) - [ ] 内部 service token 校验中间件存在且生效 - [ ] AIFA 只绑内网 -- [ ] `sys_audit_log` 有写入点(在 ai_proxy 里) +- [ ] `sys_audit_log`(或等价)在 **ai_proxy** 与 **一键入库** 有写入点 - [ ] 日志脱敏:不落原始日志/diff/截图 - [ ] Trace 每次请求生成完整记录 - [ ] `/healthz` 和 `/metrics` 端点可用 - [ ] 独立 Dockerfile,不装 Playwright - [ ] docker-compose 示例可直接拉起 +- [ ] (二期)§9.6 任务状态机、3min 超时、重试/取消、逐条结果入口(若本期承诺则本期验收) diff --git a/docs/superpowers/specs/2026-04-08-ai-failure-analysis-tech-selection.md b/docs/superpowers/specs/2026-04-08-ai-failure-analysis-tech-selection.md index 4cd79ae..23fe761 100644 --- a/docs/superpowers/specs/2026-04-08-ai-failure-analysis-tech-selection.md +++ b/docs/superpowers/specs/2026-04-08-ai-failure-analysis-tech-selection.md @@ -1,10 +1,12 @@ # AI 辅助失败原因分析 — 技术选型 - **文档类型**:技术选型(Tech Selection) -- **关联文档**:同目录下的 `2026-04-08-ai-failure-analysis-architecture.md`(架构设计) -- **状态**:Draft(待评审) +- **关联文档**: + - `2026-04-08-ai-failure-analysis-architecture.md`(架构设计) + - `2026-04-14-ai-failure-analysis-implementation-plan.md`(实现计划与分期) +- **状态**:Draft(待评审,2026-04-14 与架构同步修订) - **作者**:AI 助手 × djn -- **日期**:2026-04-08 +- **日期**:2026-04-08(初稿);**修订**:2026-04-14 --- @@ -16,6 +18,8 @@ - **架构文档**里凡是涉及具体技术名的地方,本文档都要给出**选型理由**和**备选**。 - 本文档不重复架构细节;看到"为什么 Agent 要分三阶段"之类问题请回查架构文档。 +**2026-04-14 同步说明**:**不向 AIFA 传日志 URL**;Phase B 主证据来源为 **`reports_url`(测试报告 HTML)+ `screenshot_url`(截图目录/索引)**,由 **AIFA 使用 `httpx` 直连拉取**;索引页/HTML 解析使用 **`selectolax` + `httpx`**(见 §6)。dt-report **可选**预填直链数组。**不**引入 Playwright。 + --- ## 1. 总原则 @@ -156,50 +160,48 @@ AIFA_LLM_VISION_MODEL | requests + anyio 桥接 | ✘ | 同步库强转 async 是反模式 | **复用策略**: -- AIFA 启动时建立**单例** `httpx.AsyncClient`,所有 tool(`fetch_log_html` / `fetch_screenshot_b64` / `codehub_*`)共享 +- AIFA 启动时建立**单例** `httpx.AsyncClient`,供 **`fetch_report_html` / `fetch_screenshot_b64` / `codehub_*`** 等 tool 共享(**无**按**日志** URL 的 HTML 抓取) - 针对外部调用打 timeout(架构 §8 定义) - 不复用 dt-report 的 httpx client 实例(跨进程,无意义) --- -## 6. HTML 解析 +## 6. HTML 解析(AIFA:`selectolax`) + +**架构约定**:AIFA **不**按**日志** HTML URL 抓取;对 **`reports_url`(测试报告 HTML)** 与 **截图目录索引页(HTML)** 的解析,**AIFA** 使用 **`selectolax`**(与架构 §6.2 `fetch_report_html` / `fetch_screenshot_b64` 行为一致)。 -**选择:`selectolax`** +**dt-report** 若**可选**预解析索引页,可选用同一技术栈;**非必选**。 | 候选 | 决策 | 理由 | |---|---|---| -| **selectolax** | ✓ | 基于 Modest / lexbor 引擎,比 BeautifulSoup 快 10 倍+,专为大 HTML 取正文优化 | -| BeautifulSoup4 | △ | 功能更全但慢;我们只要 `body` 纯文本,不需要 BS4 的 DOM 操作 | -| lxml | △ | 也快,但 API 更啰嗦;selectolax 封装更直观 | -| 正则直接抽 | ✘ | 上游 HTML 结构变化风险高,正则维护成本大 | -| Playwright/Headless browser | ✘ | 过度方案,日志 HTML 不需要 JS 执行;会重新引入 Playwright 依赖 | +| **selectolax**(**AIFA 推荐必选**,兼 dt-report 可选) | ✓ | 解析报告 HTML、截图索引页 | +| BeautifulSoup4 | △ | 功能更全但慢 | +| lxml | △ | API 较啰嗦 | +| Playwright/Headless browser | ✘ | 过度;不引入 | -**典型用法**: +**典型用法**(索引页解析示意): ```python from selectolax.parser import HTMLParser tree = HTMLParser(html) -text = tree.css_first("body").text(separator="\n", strip=True) +# 按现网 DOM 抽取图片直链,选择器实现阶段确定 ``` --- -## 7. MongoDB 驱动 +## 7. 证据拉取策略 -**选择:`motor`** +**选择:统一 `httpx` 拉取 + `selectolax` 解析** | 候选 | 决策 | 理由 | |---|---|---| -| **motor** | ✓ | MongoDB 官方 async 驱动;与 `pymongo` 同宗,API 熟悉度高 | -| pymongo | ✘ | 同步驱动,违反原则 3 | -| beanie / odmantic | ✘ | ORM 层过度抽象;AIFA 只做只读 `find`,不需要 schema 定义 | +| **httpx + selectolax** | ✓ | 统一覆盖报告 HTML 与截图索引页,且与现有代码风格一致 | +| Playwright/Headless browser | ✘ | 运行时重、维护复杂,不符合最小依赖原则 | +| requests + bs4 | ✘ | 同步调用不满足 async 约束 | **使用约束**: -- 只用 `find`,不用 `aggregate` / `mapReduce`(降低权限要求、减少对 Mongo 集群压力) -- 启动时建单例 `AsyncIOMotorClient`,连接池 10 -- 只读用户(架构 §8.2) - -### 7.1 字段映射配置 -因为 AIFA 不控制 Mongo schema,所有字段名走 env(架构 §8.2)。不把字段名硬编码到代码里。 +- 报告抓取统一走 `fetch_report_html(reports_url)`,并做 `max_chars` 截断。 +- 截图抓取统一走 `fetch_screenshot_b64(screenshot_url)`,支持 `image/*` 直链或索引页解析。 +- 索引页解析出的图片数量、单图大小都必须有硬上限。 --- @@ -310,11 +312,11 @@ class CodeHubClient(CodeRepoClient): |---|---|---| | 测试框架 | **pytest + pytest-asyncio** | 与 dt-report 一致 | | HTTP mock | **pytest-httpx** | httpx 官方配套 | -| Mongo mock | **mongomock-motor** | motor 配套;不必起真 Mongo | +| Report/Screenshot mock | **pytest-httpx** | 通过 httpx mock 覆盖 HTML 与图片拉取路径 | | LLM mock | 自写 fake client(实现 `LLMClient` Protocol) | 真实 LLM 调用不可用于单元测试 | | 覆盖率 | **pytest-cov** | 标配 | -**集成测试**:额外提供 docker-compose.test.yml,拉起真 Mongo + mock CodeHub + mock LLM gateway 做端到端。 +**集成测试**:额外提供 docker-compose.test.yml,拉起 mock 报告/截图源 + mock CodeHub + mock LLM gateway 做端到端。 --- @@ -347,12 +349,9 @@ pydantic-settings==2.5.0 # HTTP httpx==0.27.0 -# HTML 解析 +# HTML 解析(AIFA:reports_url + 截图索引页;与架构 §6.2 一致) selectolax==0.3.21 -# MongoDB -motor==3.5.1 - # LLM openai==1.54.0 @@ -363,7 +362,6 @@ cachetools==5.5.0 pytest==8.3.0 pytest-asyncio==0.24.0 pytest-httpx==0.32.0 -mongomock-motor==0.0.33 pytest-cov==5.0.0 ``` @@ -395,26 +393,27 @@ pytest-cov==5.0.0 ## 17. 选型影响评估 ### 17.1 对 dt-report 的影响 -**几乎零影响**: -- 后端:新增 2 个薄文件(`ai_proxy.py` + `ai_context_builder.py`),不动现有 service -- 前端:新增独立目录 `ai_analysis/`,`HistoryPage.tsx` 仅需加一行挂 Tab -- 依赖:**无新增** -- 部署:docker-compose 新增一个 service -- 数据库:**零变更** +**可控增量**(相对初版「仅两文件」已扩展,见架构 §3.3、§1.4): +- 后端:新增 **`ai_proxy.py`**、**`ai_context_builder.py`**、**一键入库 API**(文件名以实现为准);**尽量不修改**现有 service 核心逻辑,可复用 `failure_process_service` 等与「分析处理」一致的写入规则 +- 前端:新增独立目录 `ai_analysis/`(含报告、时间线、一键入库按钮),`HistoryPage.tsx` 仍建议仅加一行挂 Tab +- 依赖:**Python 侧仍可无新增**(目录解析若复用 `selectolax`,与 AIFA 对齐时需在 dt-report `requirements.txt` 评估是否已存在;若 dt-report 已含则不加) +- 部署:docker-compose 新增一个 service(AIFA) +- 数据库:**一键入库**写入既有 **`pipeline_failure_reason`** 列时**可无表结构变更**;若新增列必须走项目 SQL 迁移与 ORM 对齐 ### 17.2 对运维的影响 - 需要运维额外维护: - 一个 Docker 镜像的构建/发布 - 一套 `AIFA_*` env 的管理(尤其 API key 与 token) - 一个内网地址与防火墙规则 - - Mongo 只读账号的申请 + - 报告/截图源的访问连通性确认 - CodeHub service token 的申请 ### 17.3 对成本的影响 - **新增现金成本**:LLM 调用费(按 SLO 目标 ≤ 0.5 元/请求估算,按日请求量乘积预估月开销) - **硬上限熔断**:`AIFA_MAX_TOKENS_PER_REQUEST` 防单次失控 - **并发上限熔断**:`AIFA_MAX_CONCURRENT_ANALYSES` 防瞬时爆发 -- **用户侧速率限制**:dt-report `ai_proxy` 对同一用户限流(每分钟 ≤10、每小时 ≤50) +- **用户侧速率限制(以架构 §12.4 为准)**:**同一 `history_id` 1 分钟内最多 10 次**发起分析;可选叠加「同一用户」全局限流(如每分钟 / 每小时上限) +- **转发超时**:dt-report → AIFA 的 `httpx` 客户端建议 **SSE 总读超时 180s(3 分钟)**(与架构 §3.3、§9.6 一致) --- @@ -426,8 +425,8 @@ pytest-cov==5.0.0 - [ ] FastAPI + Uvicorn + pydantic v2 - [ ] 所有 env 经 `pydantic-settings` 加载,前缀 `AIFA_` - [ ] httpx 单例 AsyncClient 全局共享 -- [ ] selectolax 处理 HTML 日志 -- [ ] motor 单例 `AsyncIOMotorClient` +- [ ] **无日志 URL 抓取**;证据主路径为 `reports_url` + `screenshot_url` +- [ ] **AIFA** 对 `reports_url`、截图索引 HTML 使用 **selectolax**(与 `fetch_report_html` 等 tool 一致) - [ ] `openai` SDK(`AsyncOpenAI`)+ `base_url` 指向 ZhipuAI - [ ] 文本/视觉模型名完全从 env 读取 - [ ] `CodeRepoClient` Protocol 存在;初期仅实现 `CodeHubClient` @@ -438,5 +437,6 @@ pytest-cov==5.0.0 - [ ] 所有依赖在 `requirements.txt` 锁死 `==` 小版本 - [ ] 前端零新增依赖(只用原生 EventSource + crypto.randomUUID) - [ ] `HistoryPage.tsx` 改动 ≤ 1 行 +- [ ] dt-report 转发 AIFA 的 httpx 客户端配置 **180s** 级读超时(或与架构一致的可调值) - [ ] docker-compose 可拉起 dt-report + AIFA 两个 service -- [ ] docker-compose.test.yml 可跑端到端测试(含 mock Mongo/CodeHub/LLM) +- [ ] docker-compose.test.yml 可跑端到端测试(含 mock 报告/截图源 + CodeHub/LLM) diff --git a/docs/superpowers/specs/2026-04-14-ai-failure-analysis-implementation-plan.md b/docs/superpowers/specs/2026-04-14-ai-failure-analysis-implementation-plan.md new file mode 100644 index 0000000..5d557bd --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-ai-failure-analysis-implementation-plan.md @@ -0,0 +1,120 @@ +# AI 辅助失败原因分析 — 实现计划与分期 + +- **文档类型**:实现计划(Implementation / Roadmap) +- **关联文档**: + - `2026-04-08-ai-failure-analysis-architecture.md`(架构与契约) + - `2026-04-08-ai-failure-analysis-tech-selection.md`(技术选型) + - `aifa-phase-a1-service-spec.md`(**A1** 阶段规格:服务骨架、端点、DoD) + - `dt-report-phase-a2-ai-context-builder-spec.md`(**A2** 阶段规格:`ai_context_builder`、payload、DoD) + - `aifa-phase-a3-sse-report-contract-spec.md`(**A3** 阶段规格:SSE 进度与 report 契约收紧) + - `aifa-phase-b1-report-screenshot-tools-spec.md`(**B1** 阶段规格:报告/截图证据拉取 Tool、DoD) +- **状态**:Draft(随排期滚动更新) +- **日期**:2026-04-14 + +--- + +## 0. 文档目的 + +本文档描述**如何把整体能力拆成可交付特性**、**推荐实现顺序**与**穿刺(POC)范围**,不与架构文档抢职责: + +| 文档 | 回答的问题 | +|------|------------| +| 架构设计 | 系统边界、契约、降级、安全、终态行为 | +| 技术选型 | 库、协议、版本 | +| **本文档** | **先做哪块、后做哪块、穿刺做到哪一步算完成** | + +架构与技术选型以各自文件为准;分期冲突时**以架构为准**,并回写本文档。 + +--- + +## 1. 穿刺(P0 / POC)— 最高优先级 + +**目标**:勾选**任意一条**失败用例 → 触发 AI 分析 → 展示结果 → 用户 **接受** 或 **拒绝**。 + +| 动作 | 行为 | +|------|------| +| **接受** | 将当前分析结果写入 **`pipeline_failure_reason`**(至少 `failed_type` / `reason` 等与产品一致);将该条执行 **`pipeline_history.analyzed` 置为已分析**(与现网「分析处理」语义对齐,实现前对照 `failure_process_service` / `one_click_analyze_service` 确认字段组合) | +| **拒绝** | **不写库**、不更新 `analyzed`;丢弃本次草稿(前端清 state;若服务端有分析草稿 session,一并作废) | + +**建议技术形态(竖切最小)**: + +1. **入口**:列表勾选一条失败记录 +「AI 分析」按钮(穿刺可用简化入口;终态见架构 Drawer Tab)。 +2. **分析请求**:前端传 `history_id` → dt-report **只读**拼最小 payload(**不含日志 URL**;包含 `case_name`/`batch`/`platform` + `reports_url` + `screenshot_url`)→ 调 AIFA(或穿刺期 **Mock AIFA** 返回固定 JSON)。 +3. **结果展示**:结构化展示「结论 + 失败归类 + 简要依据」(可与终态 schema 子集对齐)。 +4. **接受 / 拒绝**:两个独立 API 或同一资源两种 action;**接受**必须带防误写策略(例如服务端保存 `analysis_draft_id` / 短期 token,避免前端伪造结论)。 + +**穿刺刻意不做**(避免阻塞 POC):AIFA 内完整索引页解析与多图拉取、成功 batch 多图对比、完整五 Skill、追问、批量队列、完整限流与审计、独立容器部署(可按团队情况二选一:先同进程 mock,再拆 AIFA)。 + +> 范围冻结补充(2026-04-23):**穿刺阶段明确不做 C2/C3**。 +> 即:不交付「追问 + Session(C2)」与「一键入库 owner 全规则(C3)」;穿刺仅保障单条分析闭环可演示。 + +**完成标准(DoD)**:演示路径可走通 **分析 → 接受写库+已分析 → 拒绝不写库**;测试可按此写 3~5 条用例。 + +--- + +## 2. 分期总览(特性切片) + +以下为推荐顺序;每条可单独立项、单独合并。 + +### Phase A — 单条闭环(承接穿刺) + +| ID | 特性 | 说明 | +|----|------|------| +| A1 | 正式 **AIFA 服务骨架**(**已完成**:仓库根目录 `ai-failure-analyzer/`) | FastAPI、`/v1/analyze`、内部 token、健康检查;可先单轮 LLM 无 Tool;**详见 `aifa-phase-a1-service-spec.md`** | +| A2 | **真实 payload** | `ai_context_builder`:`case_name`/`batch`/`platform` + `reports_url` + `screenshot_url`、`recent_executions`、`repo_hint`;**不传日志 URL**;截图可先直链或空;**详见 `dt-report-phase-a2-ai-context-builder-spec.md`** | +| A3 | **SSE 进度 + 报告契约** | 与架构 §4 对齐的最小 `report` 字段 | +| A4 | **接受 / 拒绝 API 终态** | 与架构 §1.4、§9.4、§12.5 一致;审计、权限 | +| A5 | **限流** | 单 `history_id` 10 次/分钟(架构 §12.4) | + +### Phase B — 分析质量与证据链 + +| ID | 特性 | 说明 | +|----|------|------| +| B1 | **Tool:报告/截图证据拉取** | `fetch_report_html` + `fetch_screenshot_b64`(含索引页解析、截断/条数上限;**无**日志 HTML URL);**详见 `aifa-phase-b1-report-screenshot-tools-spec.md`** | +| B2 | **Agent 三阶段** | Plan → Act → Synthesize(架构 §5) | +| B3 | **截图/报告 URL 拉取** | AIFA:`fetch_report_html` + `fetch_screenshot_b64`(索引页解析,架构 §8.3);可选 dt-report 预填直链 | +| B4 | **成功 batch + URL 替换 + 多图对比** | `spec_change` / `flaky` 规则与降级(架构 §4.3、§8.3) | +| B5 | **CodeHub** | list_commits / diff | + +### Phase C — 体验与运维 + +| ID | 特性 | 说明 | +|----|------|------| +| C1 | **Drawer Tab + 懒加载** | 与架构 §9 一致;`HistoryPage` 最小改动 | +| C2 | **追问 + Session** | 架构 §7、§5 追问分支(**不在穿刺范围**) | +| C3 | **一键入库与「分析处理」owner 全规则** | bug 按模块 / 非 bug 按预设映射(架构 §1.4,**不在穿刺范围**) | +| C4 | **观测与成本** | trace、metrics、token 熔断 | + +### Phase D — 二期(架构 §9.6) + +| ID | 特性 | 说明 | +|----|------|------| +| D1 | 批量勾选、后台队列、进度、重试、取消、3min 超时 | 可能引入任务存储与迁移 | + +--- + +## 3. 依赖关系(简图) + +``` +穿刺(P0) ──► Phase A(硬化服务与契约) + │ + ├──► Phase B(Tool 与证据链) + │ + └──► Phase C(Tab、追问、规则对齐) + │ + └──► Phase D(批量队列) +``` + +--- + +## 4. 与「整体文档」的关系 + +- **整体能力**:仍以 **架构 + 选型** 为单一事实来源(SSOT)。 +- **本文档**:仅跟踪 **落地顺序与穿刺 DoD**;排期变更时改本文档即可,不必反复改架构大段文字。 + +--- + +## 5. 维护约定 + +- 每完成一个 Phase,在本文档对应行打勾或更新「状态」列(可选)。 +- 若产品决定砍掉某期,在本文档标注 **已取消** 并简述原因,避免与架构正文打架。 diff --git a/docs/superpowers/specs/aifa-phase-a1-service-spec.md b/docs/superpowers/specs/aifa-phase-a1-service-spec.md new file mode 100644 index 0000000..2e93a79 --- /dev/null +++ b/docs/superpowers/specs/aifa-phase-a1-service-spec.md @@ -0,0 +1,228 @@ +# AIFA Phase A1 — 服务骨架阶段规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **A1** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(组件边界、**dt-report ↔ AIFA 契约**、错误策略;冲突时以架构为准) + 2. `2026-04-08-ai-failure-analysis-tech-selection.md`(Python 3.11、FastAPI、OpenAI SDK、日志等) + 3. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 ID 与依赖关系) +- **对应分期**:实现计划 **A1** — 正式 **AIFA(`ai-failure-analyzer`)服务骨架**:FastAPI、`POST /v1/analyze`、内部 Service Token、`GET /healthz`;**单轮 LLM、无 Tool**。 +- **状态**:Draft +- **日期**:2026-04-16 + +--- + +## 0. 文档目的 + +本文档回答:**A1 合并时必须具备哪些行为、接口、配置与验收标准**;不重复架构全文,只固化 **A1 范围内的「必须 / 可选 / 禁止」**。 + +**A1 不包含**:dt-report 侧 `ai_proxy` / `ai_context_builder`(**A2**,详见 `dt-report-phase-a2-ai-context-builder-spec.md`)、SSE 进度事件的丰富度与 `report` 全字段契约的终态收紧(**A3**)、接受/拒绝写库(**A4**)、按 `history_id` 限流(**A5**)、报告/截图拉取 / CodeHub 等 Tool(**Phase B**)。 + +--- + +## 1. 与架构、实现计划的对齐说明 + +| 主题 | 架构要求 | A1 落地方式 | +|------|----------|-------------| +| 服务形态 | 独立进程、仓库内 `ai-failure-analyzer/` 子目录 | 新增独立目录与独立依赖清单;**不与** `backend/` 混部署 | +| `POST /v1/analyze` | 请求 `application/json`;响应 **`text/event-stream`(SSE)** | A1 **必须**采用 SSE;允许 **最少条数** 的 `progress`(例如 1 条)再发 `report` | +| 认证 | `Authorization: Bearer ` | A1 **必须**实现;`/v1/*` 受保护,`/healthz` 豁免 | +| 契约字段 | §4.1 请求体、`report` §4.3 | A1:**请求体**按架构字段做 Pydantic 校验,**未传字段**可缺省/可空;**响应** `report` 提供 **最小可用子集**(见 §5.3),其余数组可为空 | +| 单轮无 Tool | 五 Skill / Tool 矩阵属后续 | A1 **禁止**实现报告/截图拉取、CodeHub;仅基于 **请求 JSON 文本** 调单轮 LLM(或 Mock) | +| 健康检查 | §11.1 `GET /healthz` | A1:**进程可用** + 可选 `checks`;**不要求** report/screenshot / CodeHub / LLM 远程探测(可标 `skipped` / `not_configured`) | +| `spec_change` / `flaky` | §4.3 无对比证据不得强判 | A1:服务端在合成/后处理阶段 **无成功侧截图相关有效输入** 时,**不得**输出 `spec_change` / `flaky`,应降级为 **`unknown`** 并可在 `data_gaps` 中说明 | + +实现计划将「**SSE 进度丰富度 + report 与 §4.3 完全对齐**」记在 **A3**:A1 完成后,A3 可在 **不改动传输层协议** 的前提下增补事件与字段校验;**A1 不得**采用「仅 `application/json` 整包返回、无 SSE」形态,以免与架构 §4 冲突。 + +--- + +## 2. 交付物清单(A1 DoD) + +以下全部满足,视为 **A1 完成**: + +1. **可启动服务**:本地或容器内可通过 Uvicorn 启动(启动命令与端口由实现文档或 README 说明;默认端口可与架构示例 **8080** 对齐)。 +2. **`GET /healthz`**:返回 HTTP 200 与 JSON body,至少包含整体 `status`(如 `ok`);依赖检查可为 **简化**(见 §6)。 +3. **`POST /v1/analyze`**: + - 校验 `Authorization: Bearer`;错误返回 **401**(与架构 §10 对 token 配置错误的 fail-loud 一致)。 + - 校验请求体 JSON(结构对齐架构 §4.1,字段允许大量可选)。 + - 响应为 **SSE**:至少包含 `event: progress`(可 1 条)与 `event: report`;异常路径 `event: error`(见 §5.2)。 +4. **LLM 配置**:模型调用的 **Base URL(或等价)** 与 **API Key** **必须**从**环境变量**读取,禁止写入仓库;支持 **Mock 模式**(见 §7)以便 CI 无密钥运行。 +5. **单轮分析**:无 Tool、无多阶段 Agent;可将 `case_context` / `recent_executions` 等 **安全截断** 后拼入 prompt。 +6. **自动化测试**:至少覆盖「无 token / 错误 token → 401」「健康检查 200」「Mock LLM 下 analyze 返回合法 SSE 且最终 `report` 可解析」。 + +--- + +## 3. 代码与工程布局(规范性要求) + +以下内容供实现时遵循(A1 合并时目录名可微调,但须保持 **版本在 URL、Schema 独立文件**): + +- 根目录:`ai-failure-analyzer/`(与架构 §3 一致)。 +- **Python 3.8+**(实现与 `pyproject.toml` 一致;技术选型 §2.1 仍推荐 3.11,与「环境仅 3.8」可并存);**FastAPI + Uvicorn + Pydantic v2**(技术选型 §2.2)。 +- 建议结构:`main` 挂载路由;`api/v1/analyze.py`;`api/v1/schemas/`(请求/响应模型);`core/config.py`(环境变量);`core/security.py`(Bearer 校验);`services/analyze_service.py`(单轮 LLM 编排)。 +- **独立** `requirements.txt`(或 `pyproject.toml`),**不**合并进 `backend/requirements.txt`。 +- **可选**:`Dockerfile`(与 dt-report 对齐:`ubuntu:20.04` + `docker/sources.list` + apt 安装 `python3`/`venv`(focal 为 3.8.x);构建上下文为**仓库根目录**)、`.env.example`(仅键名与说明,无真实密钥)。 + +--- + +## 4. 端点规格 + +### 4.1 `GET /healthz` + +- **鉴权**:无需 Bearer。 +- **响应**:`application/json`。 +- **A1 最小 body 示例**: + +```json +{ + "status": "ok", + "checks": { + "process": "ok", + "report_fetch": "skipped", + "codehub": "skipped", + "llm": "not_configured" + } +} +``` + +- **`checks.llm` 语义建议**: + - `not_configured`:未配置调用真实 LLM 所需变量且未开启 Mock; + - `ok`:已配置为 Mock 或已配置密钥且(可选)启动阶段 warmup 成功; + - 具体枚举实现阶段可细化,但须 **人类可读、稳定**。 + +A1 **不要求**对报告/截图源、CodeHub、LLM 供应商做**周期性**远程健康探测;架构 §11.1 完整 checks 可在 **B 阶段** 或运维迭代中补齐。 + +### 4.2 `POST /v1/analyze` + +- **路径**:`/v1/analyze`(版本前缀与架构 §4 一致)。 +- **鉴权**:**必须**携带 `Authorization: Bearer `;与 `AIFA_INTERNAL_TOKEN` 比对(建议使用 `secrets.compare_digest` 防计时侧信道)。 +- **请求头**:接受 `Content-Type: application/json`;若转发链存在 `X-Request-ID`,**应记录**;若无则服务端生成 UUID4。 +- **请求体**:结构对齐架构 **§4.1**(`session_id`、`mode`、`case_context`、`recent_executions`、`repo_hint` 等);字段 **大部分可选**,**不得**要求 AIFA 访问 MySQL 补数据(架构 §4.1 末段)。 +- **成功响应**:`Content-Type: text/event-stream`;`Cache-Control: no-cache`;`Connection` 等按 SSE 常规实践。 +- **HTTP 状态码**: + - 流式成功:**200**(即使业务 `report.status` 为 `partial` / `error`,仍由 SSE `report` 或 `error` 事件表达,与架构「partial 仍 200」方向一致;若 A1 简化为「LLM 失败则发 `event: error` 后结束」,须在实现中固定并写入测试)。 + - 未授权:**401**。 + - 请求体非法:**400**(可选用 FastAPI 校验错误体;是否通过 SSE 返回由实现二选一,但须在 README 说明;**推荐** JSON 400 以便客户端区分「协议错误」与「分析失败」)。 + +--- + +## 5. SSE 与 `report` 最小契约(A1) + +### 5.1 事件类型(A1 最小集) + +与架构 **§4.2** 对齐,A1 **至少**支持: + +| `event` | 说明 | +|---------|------| +| `progress` | `data` 为 JSON:`{"stage": string, "message": string}`;`stage` 可枚举简化,如 `llm_single` | +| `report` | `data` 为 **完整一层** JSON:含 `session_id`、`status`、`report`、`trace`(见 §5.3) | +| `error` | `data` 为 JSON:`{"error_code": string, "message": string}`(与架构 §4.2 形态一致) | + +**不要求** A1 实现 `progress` 与真实 Plan/Act 阶段一一对应(属 **A3 / B2**)。 + +### 5.2 `event: error` 触发条件(A1 建议) + +至少包含:**内部 token 校验失败**(此类也可在进流前直接 HTTP 401,不进入 SSE)、**请求体验证失败**、**LLM 调用不可恢复失败**(如 401/403、连接拒绝)。 +**不要求** A1 实现架构 §10 全部 Soft/Partial 分级;但 **禁止**在日志中打印 API Key 或完整 Bearer。 + +### 5.3 `event: report` 的 JSON 最小字段(A1) + +顶层对象 **必须**包含(命名与架构 §4.3 **一致**,便于 A3 收紧): + +| 字段 | 类型 | A1 要求 | +|------|------|---------| +| `session_id` | string | 与请求一致或回显请求值 | +| `status` | `"ok" \| "partial" \| "error"` | 单轮成功且解析成功 → 通常 `ok`;解析降级 → `partial` 或 `error` 由实现定义并测准 | +| `report` | object | 见下表 | +| `trace` | object | 至少含 `llm_input_tokens`、`llm_output_tokens`、`elapsed_ms`(整数;未知可为 `0`);`skills_invoked` 可为 `["llm_single"]` 等 | + +**`report` 子对象(A1 最小)**: + +| 字段 | A1 要求 | +|------|---------| +| `failure_category` | 必须有;取值 `bug \| spec_change \| flaky \| env \| unknown`;**无足够对比证据时禁止** `spec_change` / `flaky`(见 §1 表) | +| `summary` | 建议有(短句) | +| `detailed_reason` | 建议有(长文本占位亦可,但须为字符串) | +| `confidence` | 建议有(0~1 浮点) | +| `data_gaps` | 建议有(字符串数组,可为空) | +| `evidence` | 可有;允许空数组 `[]` | +| `stage_timeline` | 可有;允许单元素或空数组 | +| `verdict`、`rationale_summary`、`suspect_patches`、`suggested_next_steps` | **可选**;A1 可为空或省略 | + +**LLM 输出解析策略(A1)**:推荐 **强制模型输出 JSON**(与 `report` 最小子集同构的片段),服务端校验 + 缺省填充;解析失败时 `status`/`report` 与 `event: error` 的组合方式由实现固定并测试覆盖。 + +--- + +## 6. 环境变量与配置(A1 必须) + +以下变量名 **为建议命名**,实现阶段可统一前缀为 `AIFA_*`,但须在 **`.env.example` 与 README** 中列出全部键。 + +| 变量 | 必填 | 说明 | +|------|------|------| +| `AIFA_INTERNAL_TOKEN` | 生产必填 | dt-report(或脚本)调用 AIFA 时使用的 **内部 Service Token**;**禁止**出现在前端或仓库明文 | +| `AIFA_LLM_BASE_URL` | 调真实 LLM 时必填 | 兼容 OpenAI 兼容协议的 **API Base**;Mock 模式下可忽略 | +| `AIFA_LLM_API_KEY` | 调真实 LLM 时必填 | **禁止**日志明文打印;Mock 模式下可忽略 | +| `AIFA_LLM_MODEL` | 建议 | 模型名;缺省值由实现文档约定 | +| `AIFA_LLM_MOCK` | 可选 | 例如 `1` / `true` 时 **不发起外网调用**,返回固定或可配置 fixture,供 CI | +| `AIFA_PORT` | 可选 | 监听端口,默认建议 `8080` | + +**可选后续变量**(可在 A1 README 预留说明,实现可延后):`AIFA_MAX_TOKENS_PER_REQUEST`、温度、单请求超时等(架构 §11.4 / 技术选型)。 + +--- + +## 7. 安全与合规(A1) + +- **密钥**:仅环境变量 / 容器注入;日志、trace、异常栈中 **脱敏**。 +- **网络**:假定仅内网可达;**不在** A1 实现浏览器直连 CORS 生产配置(若本地调试需要 CORS,须默认关闭或限制 origin)。 +- **请求体**:对嵌入请求的大段文本做 **长度上限**(具体字节数实现阶段定义),超限返回 **400** 或 `partial` + `data_gaps`(二选一并文档化)。 + +--- + +## 8. 日志(A1 最小) + +与技术选型「标准库 logging + 不冗余」方向一致: + +- 使用 `logging.getLogger(__name__)`。 +- **INFO**:分析请求完成(含 `request_id`、`session_id`、`elapsed_ms`、token 统计摘要);**不在**循环内逐条刷屏。 +- **WARNING**:可恢复错误(如 LLM 超时重试策略若 A1 未做则可为单次失败)。 +- **ERROR**:未预期异常使用 `logger.exception`。 +- **禁止**:打印完整请求体中的敏感 URL 参数、Bearer、API Key。 + +--- + +## 9. 测试与验收 + +| 用例 | 期望 | +|------|------| +| 无 `Authorization` 调用 `/v1/analyze` | 401 | +| 错误 Bearer | 401 | +| `GET /healthz` | 200 + JSON | +| `AIFA_LLM_MOCK` 开启时 `POST /v1/analyze` | 200,SSE 可解析,最终 `report.failure_category` 合法且满足 §1 降级规则 | +| 请求体缺少 `session_id` 等必填项 | 400(若 A1 将 `session_id` 列为必填) | + +**手动验收**:`curl`/`httpx` 示例命令写入 README;示例中使用占位 token。 + +--- + +## 10. 明确非目标(A1 禁止范围) + +- 不实现 **dt-report** 任何路由、代理、写库。 +- 不实现 **httpx 拉取截图/报告**、**CodeHub**、多 Skill、Plan/Act/Synthesize、追问 session 持久化。 +- 不实现 **`/metrics`**、完整 JSONL **trace 文件**、成本按天聚合(架构 §11.3–11.5;可列在后续阶段)。 +- 不修改 **MySQL** 表结构或 `database/` 迁移(AIFA **零 MySQL**)。 + +--- + +## 11. 维护约定 + +- A1 实现合并后:在 `2026-04-14-ai-failure-analysis-implementation-plan.md` 的 A1 行更新状态(打勾或「已完成」)可选。 +- 若本规格与架构正文冲突:**以架构为准**,并修订本文件 revision。 + +### 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-16 | 初稿:A1 范围、DoD、SSE 最小集、环境变量、健康检查、非目标 | +| 2026-04-16 | 文件名定为 `aifa-phase-a1-service-spec.md`(无日期前缀);实现计划增加引用 | +| 2026-04-16 | 仓库根目录新增 `ai-failure-analyzer/` 实现 A1(与本文档 DoD 对齐) | +| 2026-04-17 | AIFA `Dockerfile` 改为与 dt-report 同基础镜像,镜像内 deadsnakes 安装 Python 3.11;构建自仓库根目录 | +| 2026-04-17 | 源码与依赖声明兼容 **Python 3.8+**(与 3.10+ 行为一致),便于与 dt-report 同版本 Python | +| 2026-04-17 | AIFA `Dockerfile` 与 dt-report 一致改为仅 **apt 安装 python3**(3.8),去掉 deadsnakes/PPA | diff --git a/docs/superpowers/specs/aifa-phase-a3-sse-report-contract-spec.md b/docs/superpowers/specs/aifa-phase-a3-sse-report-contract-spec.md new file mode 100644 index 0000000..ed131ee --- /dev/null +++ b/docs/superpowers/specs/aifa-phase-a3-sse-report-contract-spec.md @@ -0,0 +1,179 @@ +# AIFA Phase A3 — SSE 进度与报告契约阶段规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **A3** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(§4.2 SSE 事件、§4.3 report 契约;冲突时以架构为准) + 2. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 **A3** 与依赖关系) + 3. `aifa-phase-a1-service-spec.md`(A1 已交付最小 SSE/最小 report;A3 在其基础上收紧) + 4. `dt-report-phase-a2-ai-context-builder-spec.md`(A2 请求 payload 来源与字段可空策略) +- **对应分期**:实现计划 **A3** — **SSE 进度 + 报告契约**:在不改变传输协议(仍为 `text/event-stream`)前提下,补齐并收紧 `progress` 事件语义与 `report` 字段契约。 +- **状态**:Draft +- **日期**:2026-04-20 + +--- + +## 0. 文档目的 + +本文档回答:**A3 合并时必须具备哪些事件行为、字段约束、错误语义与验收标准**;不重复架构全文,只固化 **A3 范围内的「必须 / 可选 / 禁止」**。 + +**A3 不包含**(由其他分期负责): + +| 分期 | 内容 | +|------|------| +| **A1** | 服务骨架、`/v1/analyze` 入口、内部 token、最小 SSE | +| **A2** | dt-report 侧 `ai_context_builder` 拼装真实请求 payload | +| **A4** | 接受/拒绝写库(`pipeline_failure_reason`、`analyzed`) | +| **A5** | 按 `history_id` 限流与风控策略 | +| **Phase B** | 报告/截图索引解析/CodeHub Tool、多阶段 Agent 质量优化 | + +--- + +## 1. 职责边界 + +### 1.1 必须满足 + +- **传输层不变**:`POST /v1/analyze` 继续返回 **SSE**(`text/event-stream`),A3 不得回退为整包 JSON 一次性返回。 +- **事件语义收紧**:`event: progress` 的 `stage` 与 `message` 必须可稳定被前端消费;不得再使用无语义、不可枚举或随机化阶段名。 +- **报告契约收紧**:`event: report` 的 `data` 必须符合架构 §4.3 的对象形状,至少满足本文件 §3 的必填集与类型约束。 +- **错误路径明确**:可恢复/不可恢复异常应通过 `event: error` 或 `report.status=partial|error` 表达,行为固定并可测试。 +- **兼容 A2 缺省字段**:当请求体中某些上下文字段为空或缺省时,A3 必须按降级规则输出 `data_gaps`,而非抛未捕获异常。 + +### 1.2 明确禁止 + +- **禁止**新增或要求 dt-report 改协议(如改成 WebSocket、轮询、HTTP JSON 整包)。 +- **禁止**输出与架构冲突的 `failure_category`(例如新增未定义枚举)或字段命名漂移。 +- **禁止**在无成功侧截图对比证据时强判「规格变更,用例需适配」或「用例不稳定,需加固」(见 §4 硬规则)。 +- **禁止**在 SSE 过程中输出不可解析 JSON(包括单引号 JSON、尾逗号、截断对象)。 + +--- + +## 2. SSE 事件契约(A3) + +### 2.1 事件类型与顺序 + +A3 至少支持下列事件类型: + +| `event` | 说明 | A3 要求 | +|---------|------|---------| +| `progress` | 阶段进度事件 | 至少 2 条;建议覆盖「开始分析」与「合成结论前」关键节点 | +| `report` | 最终报告事件 | 成功路径必须发送且仅发送 1 条 | +| `error` | 失败事件 | 不可恢复错误可直接发送并结束流 | + +**顺序约束(规范)**: + +1. 正常路径:`progress*` → `report`(结束)。 +2. 失败路径:`progress*` → `error`(结束),或直接 `error`(结束)。 +3. 同一请求内,`report` 与 `error` **二选一终态**,不得同时作为最终事件重复发送。 + +### 2.2 `progress.data` 结构 + +`progress` 的 `data` 必须是 JSON 对象: + +```json +{ + "stage": "plan | report_analysis | screenshot_analysis | code_blame | synthesis | finalize", + "message": "中文进度文案" +} +``` + +- `stage`:字符串枚举,允许实现阶段缺省部分值,但必须来自**固定集合**。 +- `message`:用户可读中文文案,禁止空字符串。 +- 可选扩展字段(不影响兼容):`elapsed_ms`、`percent`、`detail`。 + +--- + +## 3. `report` 契约(A3 最小终态) + +`event: report` 的 `data` 为完整 JSON 对象,至少包含以下结构: + +| 字段 | 类型 | A3 要求 | +|------|------|---------| +| `session_id` | string | 必填;与请求同一会话一致 | +| `status` | `"ok" \| "partial" \| "error"` | 必填;语义稳定 | +| `report` | object | 必填;见下表 | +| `trace` | object | 必填;至少有 `skills_invoked`、`llm_input_tokens`、`llm_output_tokens`、`elapsed_ms` | + +`report` 子对象最小必填: + +| 字段 | 类型 | A3 要求 | +|------|------|---------| +| `failure_category` | enum | 固定为:`bug`、`环境问题`、`规格变更,用例需适配`、`用例不稳定,需加固`、`unknown` | +| `verdict` | enum/string | `product_bug \| env_issue \| test_flaky \| infra \| unknown`(或与产品约定等价枚举) | +| `confidence` | number | 0~1 浮点,超界需裁剪或降级 | +| `summary` | string | 一句话结论,非空 | +| `detailed_reason` | string | 详细原因,非空 | +| `stage_timeline` | array | 元素含 `stage`、`message`、`elapsed_ms`;允许空数组但字段必须存在 | +| `evidence` | array | 元素至少含 `id`、`type`、`source`、`snippet`、`reference` | +| `data_gaps` | array[string] | 缺失证据与降级原因;无缺失时可空数组 | + +可选字段(建议有): + +- `rationale_summary` +- `suspect_patches` +- `suggested_next_steps` + +其中 `failure_category = unknown` 时,表示当前证据不足以形成可直接应用的失败归因;下游(如 A4 接受写库)应提示人工复核,避免直接应用该结论。 + +--- + +## 4. 业务硬规则(A3 必须固化) + +### 4.1 `spec_change` / `flaky` 证据约束 + +当 `success_screenshot_urls` 为空、不可访问,或对比证据不足时: + +- **不得**将 `failure_category` 强判为「规格变更,用例需适配」或「用例不稳定,需加固」; +- 应降级为 `unknown`(或日志/历史支持的次优结论); +- 必须在 `data_gaps` 写明「成功侧截图证据不足」等原因。 + +### 4.2 降级一致性 + +- 若核心字段可生成但证据不完整:`status=partial`,同时返回可展示 `report`。 +- 若完全不可生成报告:发送 `event:error` 并结束(或 `status=error` 的 `report`,二选一并固定)。 +- 以上策略在实现、测试、README 中保持一致,避免前后端对终态判断分歧。 + +--- + +## 5. 验收标准(A3 DoD) + +以下全部满足,视为 **A3 完成**: + +1. **协议一致性**:`POST /v1/analyze` 仍为 SSE,Content-Type 正确,前端可流式消费。 +2. **进度可观测**:在正常分析路径下,至少出现 2 条 `progress`,`stage` 与 `message` 可稳定解析。 +3. **报告可校验**:最终 `report` 事件可通过 Pydantic/JSON Schema 校验(按 §3 必填集)。 +4. **硬规则生效**:构造无成功截图样例时,不会输出「规格变更,用例需适配」或「用例不稳定,需加固」,且 `data_gaps` 有解释。 +5. **异常可消费**:上游 LLM 失败、外部依赖失败时,客户端可收到可解析 `error` 事件或 `status=error|partial` 的 `report`。 +6. **回归通过**:A1/A2 相关已有测试不被破坏。 + +--- + +## 6. 推荐测试清单 + +| 用例 | 输入场景 | 期望 | +|------|----------|------| +| 正常成功流 | 请求体完整、Mock LLM 正常 | `progress>=2` 且最终 `report` 满足 §3 | +| 缺少成功截图 | `success_screenshot_urls=[]` | `failure_category` 不能是「规格变更,用例需适配」或「用例不稳定,需加固」,`data_gaps` 说明原因 | +| 依赖失败 | 模拟 LLM 401/超时 | 收到 `error` 或 `status=error|partial`,客户端可解析 | +| 字段缺省 | A2 只传最小上下文 | 不崩溃,`report` 仍可输出,缺口进入 `data_gaps` | +| 事件顺序 | 正常/异常路径 | `report` 与 `error` 不同时作为终态重复发送 | + +--- + +## 7. 与相邻分期衔接 + +- **对 A2**:A2 负责「输入真实化」;A3 负责「输出契约化」。A3 不新增 MySQL 读取职责。 +- **对 A4**:A4 的接受/拒绝写库依赖 A3 的 `report.summary`、`detailed_reason`、`failure_category` 稳定输出。 +- **对 B 阶段**:B1/B3/B5 提升证据质量;A3 先固定输出结构,后续仅增强字段内容质量。 + +--- + +## 8. 维护约定 + +- 若架构 §4.2 / §4.3 更新:先改架构 SSOT,再同步本文件与实现。 +- 若 `report` 字段新增且影响前端解析:必须在本文件追加兼容策略(向后兼容/灰度字段)。 + +### 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-20 | 初稿:A3 范围、SSE 事件顺序、report 最小终态字段、硬规则与 DoD | diff --git a/docs/superpowers/specs/aifa-phase-a4-apply-failure-reason-spec.md b/docs/superpowers/specs/aifa-phase-a4-apply-failure-reason-spec.md new file mode 100644 index 0000000..491be70 --- /dev/null +++ b/docs/superpowers/specs/aifa-phase-a4-apply-failure-reason-spec.md @@ -0,0 +1,205 @@ +# dt-report Phase A4 — 接受/拒绝与一键入库阶段规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **A4** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(§1.4、§3.3、§9.4、§12.5;冲突时以架构为准) + 2. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 **A4** 与依赖关系) + 3. `aifa-phase-a3-sse-report-contract-spec.md`(A4 依赖其稳定输出字段) +- **对应分期**:实现计划 **A4** — **接受 / 拒绝 API 终态**:用户显式确认后,将 AI 结论按规则写入业务库,并落审计与权限校验。 +- **状态**:Draft +- **日期**:2026-04-20 + +--- + +## 0. 文档目的 + +本文档回答:**A4 合并时必须具备哪些接口行为、写库边界、权限审计与验收标准**;不重复架构全文,只固化 **A4 范围内的「必须 / 可选 / 禁止」**。 + +**A4 不包含**(由其他分期负责): + +| 分期 | 内容 | +|------|------| +| **A1** | AIFA 服务骨架、`/v1/analyze`、内部 token、健康检查 | +| **A2** | `ai_context_builder` 与分析请求 payload 组装 | +| **A3** | SSE 进度与 `report` 输出契约收紧 | +| **A5** | 按 `history_id` 限流策略 | +| **Phase B/C/D** | Tool 证据链、追问、批量队列与运维增强 | + +--- + +## 1. 职责边界 + +### 1.1 必须满足 + +- **显式确认写库**:仅当用户点击「一键设置到失败原因」(或等价确认动作)时,才允许写库。 +- **写库目标明确**:将 AI 结论映射写入既有 `pipeline_failure_reason` 约定字段(至少 `failed_type`、`reason`,以及业务需要的 `owner`、`analyzer` 等)。 +- **分析状态同步**:成功写入后,应将对应 `pipeline_history.analyzed` 置为已分析(与现网「分析处理 / 一键分析」语义一致)。 +- **鉴权与授权**:接口必须受 `JWT + 权限校验` 保护,未登录或无权用户不得写库。 +- **审计闭环**:每次接受/拒绝都需有可追溯审计记录(至少用户、history、动作、结果、时间)。 + +### 1.2 明确禁止 + +- **禁止静默写库**:分析完成后不得自动写入 `pipeline_failure_reason`。 +- **禁止越权写库**:不得绕过现有权限体系直接落库。 +- **禁止将分析过程全文入业务表**:A4 仅落业务结论字段,不落完整推理过程。 +- **禁止违反数据库红线**:不得做既有表 `ALTER / DROP`、禁止对 `pipeline_history` / `pipeline_overview` 执行 `DELETE`、禁止 ORM 自动建表。 + +--- + +## 2. 接口语义(A4) + +### 2.1 建议接口形态 + +为减少前后端歧义,建议固定以下语义(命名可微调): + +1. `POST /api/v1/ai/apply-failure-reason` + - 含义:用户**接受**当前分析结论并申请写库。 +2. `POST /api/v1/ai/reject-failure-reason`(可选) + - 含义:用户**拒绝**当前分析草稿,不写库,仅记录动作审计。 + - 若团队不希望新增 reject 路由,也可由前端本地丢弃并仅保留 apply 接口;但必须在产品与审计策略中明确。 + +### 2.2 `apply` 最小请求体(语义) + +| 字段 | 类型 | A4 要求 | +|------|------|---------| +| `history_id` | integer | 必填;定位目标失败执行 | +| `failure_category` | string | 必填;来自 A3 `report.failure_category`,且取值必须属于库内 `failed_type` 的可写子集 | +| `detailed_reason` | string | 必填;来自 A3 `report.detailed_reason`,需非空 | +| `session_id` | string | 建议;用于追溯会话 | +| `analysis_draft_id` / `version` / `nonce` | string | 建议至少一个;用于防重放、防误写 | + +**说明**:A4 需要校验输入来源可信性(见 §5),避免前端伪造任意结论直接写库。 + +### 2.3 响应语义(建议) + +- **成功**:返回 200 + 结构化 JSON(含 `history_id`、写入结果、是否更新 `analyzed`)。 +- **参数错误**:400(字段缺失、枚举非法、原因为空)。 +- **未授权 / 无权限**:401 / 403。 +- **冲突**:409(例如版本戳不匹配、目标记录已被他人先更新)。 +- **服务异常**:500(返回可读中文错误,不泄露敏感信息)。 + +--- + +## 3. 业务映射规则(A4 核心) + +### 3.1 `failure_category` -> `failed_type` + +- `failure_category` 为库内 `failed_type` 的**可写子集**,子集内值采用**同值直写**,不做二次映射。 +- 服务端必须校验该值是否在「AIFA 允许子集」内;不在子集内时返回 **400**,并给出明确中文错误信息。 + +### 3.2 `detailed_reason` -> `reason` + +- 采用 AI 结论文本写入 `reason`(或等价业务字段)。 +- 应做基础清洗:去除纯空白、长度上限控制(超限截断或拒绝,需固定策略)。 + +### 3.3 `owner` 规则对齐现网 + +按架构口径对齐现有「分析处理 / 一键分析」: + +- AI 归类为 `bug`:按模块解析并设置跟踪人。 +- 其他归类:按既有失败类型->跟踪人映射设置。 +- 映射缺失时不得无声失败,应有降级或报错策略(实现前定稿并固化测试)。 + +### 3.4 覆盖策略 + +若同 `(case_name, failed_batch, platform)` 已存在归因记录,需在实现前固定策略: + +- **二次确认**(推荐):默认不直接覆盖人工结论。 +- **upsert**:允许覆盖,但必须记录审计并可追踪前值/后值(至少行为可追溯)。 + +--- + +## 4. 接受与拒绝的终态定义 + +| 动作 | 写 `pipeline_failure_reason` | 更新 `pipeline_history.analyzed` | 审计 | +|------|------------------------------|----------------------------------|------| +| **接受(apply)** | 是 | 是 | 必须 | +| **拒绝(reject)** | 否 | 否 | 建议至少记录动作 | + +补充约束: + +- 拒绝后当前分析草稿应失效(前端清理会话态;若服务端有草稿会话,也应作废)。 +- 接受成功后,以业务表持久化结果为准;会话态仅作为展示缓存。 + +--- + +## 5. 安全与审计(A4 必须) + +### 5.1 安全要求 + +- `apply` 接口必须在 dt-report 侧执行,不允许浏览器直连 AIFA 写库。 +- 校验 `JWT` 身份与业务权限。 +- 对关键字段做防伪/防重放校验(`analysis_draft_id`、版本戳、短期 token、nonce 等至少一种)。 + +### 5.2 审计要求 + +建议审计字段(表名以现网为准,如 `sys_audit_log`): + +- `user_employee_id` +- `history_id` +- `session_id` +- `action`(`apply_failure_reason` / `reject_failure_reason`) +- `result_status`(`success` / `denied` / `conflict` / `failed`) +- `failure_category`(可选) +- `elapsed_ms`(可选) + +必须保证:出现写库异常、权限拒绝、冲突等路径时,也能检索到对应审计事件。 + +--- + +## 6. 分层落位建议(与项目规则对齐) + +为符合项目 `Model -> Schema -> Service -> API` 分层约定,A4 建议: + +- **Schema**:定义 apply/reject 的请求与响应模型。 +- **Service**:封装字段校验、幂等/冲突判断、写库事务、审计调用。 +- **API**:仅做参数校验、权限注入、错误码转换,不承载业务映射细节。 + +**非目标**:A4 不引入新业务表;若确有新增表需求,必须遵循 `database/` SQL 迁移规范并先评审。 + +--- + +## 7. 验收标准(A4 DoD) + +以下全部满足,视为 **A4 完成**: + +1. **接受可落库**:`apply` 成功后,`pipeline_failure_reason` 目标字段按规则写入,且 `pipeline_history.analyzed` 正确更新。 +2. **拒绝不落库**:拒绝路径不写业务归因、不更新 `analyzed`,但动作可追踪。 +3. **权限生效**:未登录/无权用户无法调用写库成功。 +4. **审计完整**:成功、拒绝、失败、冲突均有审计记录。 +5. **子集直写与规则稳定**:`failure_category` 在允许子集内可同值直写到 `failed_type`,且 `owner` 规则在测试中可验证并与现网一致。 +6. **幂等/冲突可控**:重复提交或并发提交有确定行为(成功幂等或返回冲突,二选一并固定)。 + +--- + +## 8. 推荐测试清单 + +| 用例 | 输入场景 | 期望 | +|------|----------|------| +| 接受成功 | 合法 JWT + 合法 payload | 写 `pipeline_failure_reason` + 更新 `analyzed` + 审计成功 | +| 拒绝成功 | 合法 JWT + reject 动作 | 不写库、不改 `analyzed`,有拒绝审计 | +| 无权限 | 无 JWT 或权限不足 | 401/403,无业务写入,有拒绝审计 | +| 非法枚举值 | `failure_category` 不在 AIFA 允许子集内 | 返回 400,错误信息可读,且无业务写入 | +| 冲突场景 | 版本戳不一致 / 并发提交 | 返回 409 或幂等成功(与策略一致) | +| 非法参数 | 空 `detailed_reason` / 缺字段 | 400,错误信息可读 | + +--- + +## 9. 与相邻分期衔接 + +- **依赖 A3**:A4 依赖 `report.failure_category`、`report.detailed_reason` 等字段稳定输出。 +- **对 A5**:A5 是分析入口限流;A4 仍需独立考虑写库接口防重放与并发冲突。 +- **对 C3**:C3 可在 A4 基础上继续完善 owner 全规则和一键入库体验。 + +--- + +## 10. 维护约定 + +- 若架构 §1.4 / §9.4 / §12.5 更新:先改架构 SSOT,再同步本文档与实现。 +- 若产品在「覆盖策略 / 冲突策略 / 拒绝是否落审计」上有调整,必须在本文件留痕更新。 + +### 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-20 | 初稿:A4 范围、接受/拒绝终态、写库映射、权限审计、DoD 与测试清单 | diff --git a/docs/superpowers/specs/aifa-phase-a5-rate-limit-spec.md b/docs/superpowers/specs/aifa-phase-a5-rate-limit-spec.md new file mode 100644 index 0000000..9e41afb --- /dev/null +++ b/docs/superpowers/specs/aifa-phase-a5-rate-limit-spec.md @@ -0,0 +1,174 @@ +# dt-report Phase A5 — 分析入口限流阶段规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **A5** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(§12.4 速率限制、§3.3 dt-report 代理层职责;冲突时以架构为准) + 2. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 **A5** 与依赖关系) + 3. `aifa-phase-a4-apply-failure-reason-spec.md`(A5 与 A4 的边界:A5 仅管分析入口,不替代写库防重放) +- **对应分期**:实现计划 **A5** — **限流**:同一 `history_id` 在 1 分钟窗口内最多触发 10 次分析请求,超限返回 429 中文提示。 +- **状态**:Draft +- **日期**:2026-04-21 + +--- + +## 0. 文档目的 + +本文档回答:**A5 合并时必须具备哪些限流行为、统计口径、错误语义与验收标准**;不重复架构全文,只固化 **A5 范围内的「必须 / 可选 / 禁止」**。 + +**A5 不包含**(由其他分期负责): + +| 分期 | 内容 | +|------|------| +| **A1** | AIFA 服务骨架、`/v1/analyze`、内部 token、健康检查 | +| **A2** | `ai_context_builder` 与分析请求 payload 组装 | +| **A3** | SSE 进度与 `report` 输出契约收紧 | +| **A4** | 接受/拒绝写库、权限与审计闭环 | +| **Phase B/C/D** | Tool 证据链、追问体验、批量队列与运维增强 | + +--- + +## 1. 职责边界 + +### 1.1 必须满足 + +- **限流粒度固定**:按 `history_id` 做限制,不按 case_name、不按 session_id 替代。 +- **硬指标固定**:同一 `history_id` 在 1 分钟内最多 10 次「发起分析」请求(含用户快速重试)。 +- **入口统一生效**:所有进入 AIFA 分析的 dt-report API 入口均应经过同一限流检查(`initial` 与 `follow_up` 均纳入)。 +- **超限响应固定**:返回 **429**,并给出可读中文提示,不可返回 500 或静默降级。 +- **失败可观测**:命中限流需可记录日志,便于后续排障与成本审计。 + +### 1.2 明确禁止 + +- **禁止改口径**:不得将 10 次/分钟改成更宽松阈值,除非产品与架构文档先变更。 +- **禁止绕过限流直连 AIFA**:浏览器仍只能经 dt-report 代理调用,不可新增绕过路径。 +- **禁止仅前端限流**:必须以服务端限流为准;前端可做提示但不能替代后端约束。 +- **禁止限流后继续转发**:命中限流时不得继续调用 AIFA。 + +--- + +## 2. 接口行为与错误语义(A5) + +### 2.1 适用范围 + +A5 限流作用于「发起分析」接口(命名以实现为准,例如 `POST /api/v1/ai/analyze`),请求体至少包含: + +| 字段 | 类型 | A5 要求 | +|------|------|---------| +| `history_id` | integer | 必填;作为限流 key | +| `mode` | string | 可选;`initial` / `follow_up` 均计入同一额度 | +| `session_id` | string | 可选;用于追踪,不参与限流 key | + +### 2.2 返回语义 + +- **未超限**:正常进入后续流程(上下文构建、转发 AIFA、SSE 回传)。 +- **超限**:返回 `HTTP 429`,JSON body 含中文错误信息。 + +建议错误体(字段名可微调但语义需稳定): + +```json +{ + "code": "AI_ANALYZE_RATE_LIMITED", + "message": "同一失败记录在 1 分钟内最多发起 10 次分析,请稍后重试", + "history_id": 123456 +} +``` + +--- + +## 3. 限流规则细化 + +### 3.1 计数窗口 + +- A5 可采用**固定窗口**或**滑动窗口**,二选一并在实现中固定。 +- 无论选哪种窗口策略,用户可感知结果必须满足「近 1 分钟最多 10 次」这一产品语义。 + +### 3.2 计数口径(必须固定) + +- **计入**:所有到达分析入口并通过基本参数校验的请求(包含手动重试)。 +- **建议计入**:`follow_up` 追问请求(与架构建议保持一致,避免通过追问绕过额度)。 +- **明确不计入**:与分析无关的接口(如 A4 的 apply/reject 写库接口)。 + +### 3.3 限流 key 设计 + +- 最小 key:`history_id`。 +- 可选增强 key:`history_id + user_employee_id` 仅作为额外全局护栏补充,**不得削弱**单 `history_id` 约束。 + +--- + +## 4. 落位建议(与现有分层对齐) + +为符合 dt-report 代理层职责,A5 建议在代理服务层统一实现限流: + +- **Schema**:保证 `history_id` 必填且类型正确。 +- **Service / Proxy**:执行限流判断与计数写入,命中后直接返回 429。 +- **API**:只负责调用限流服务与返回标准错误,不嵌入复杂计数逻辑。 + +可选实现载体(按现网条件选型): + +- 进程内缓存(单实例场景,落地快) +- Redis 计数(多实例更稳,推荐中长期) + +--- + +## 5. 日志与可观测性(A5 必须) + +每次命中限流建议记录一条结构化日志,至少包含: + +- `history_id` +- `user_employee_id`(若可获取) +- `session_id`(若可获取) +- `mode` +- `window_seconds`(固定 60) +- `threshold`(固定 10) +- `current_count` + +要求: + +- 日志级别建议 `WARNING`。 +- 日志中不得包含敏感信息(token、密钥、完整凭据)。 + +--- + +## 6. 验收标准(A5 DoD) + +以下全部满足,视为 **A5 完成**: + +1. **阈值生效**:同一 `history_id` 在 1 分钟内第 11 次请求稳定返回 429。 +2. **提示清晰**:429 响应包含可读中文说明,前端可直接展示。 +3. **无绕过路径**:分析入口所有调用路径均经过同一限流逻辑。 +4. **不中断正常请求**:未超限请求行为与 A2/A3 一致,不引入协议回归。 +5. **可观测**:命中限流时有可检索日志,字段满足 §5 最小集合。 + +--- + +## 7. 推荐测试清单 + +| 用例 | 输入场景 | 期望 | +|------|----------|------| +| 阈值边界 | 同一 `history_id` 连续请求 10 次 | 10 次均可进入分析流程 | +| 超限命中 | 同一 `history_id` 第 11 次请求(1 分钟内) | 返回 429 + 中文提示 | +| 窗口恢复 | 超限后等待窗口过期再请求 | 请求恢复可用 | +| 不同 history 隔离 | `history_id=A` 高频,`history_id=B` 低频 | A 命中限流不影响 B | +| 模式一致性 | `initial` 与 `follow_up` 混合请求 | 计数口径符合 §3.2 固定策略 | +| 非分析接口隔离 | 调用 A4 apply/reject | 不受 A5 分析入口限流影响 | + +--- + +## 8. 与相邻分期衔接 + +- **依赖 A2/A3**:A5 不改变 payload 结构与 SSE 协议,仅在入口增加流量护栏。 +- **与 A4 边界**:A5 解决「发起分析频率」问题;A4 仍需独立处理写库防重放与冲突。 +- **对 C/D 阶段**:未来引入批量队列后,应在任务入口复用 A5 或升级为队列级限流,并保留单 `history_id` 下限约束。 + +--- + +## 9. 维护约定 + +- 若架构 §12.4 的阈值或口径调整:先更新架构 SSOT,再同步本文件与实现。 +- 若追问是否计入口径有产品决策变更:必须更新 §3.2 与测试清单,避免前后端认知分叉。 + +### 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-21 | 初稿:A5 范围、`history_id` 10 次/分钟规则、429 语义、DoD 与测试清单 | diff --git a/docs/superpowers/specs/aifa-phase-b1-report-screenshot-tools-spec.md b/docs/superpowers/specs/aifa-phase-b1-report-screenshot-tools-spec.md new file mode 100644 index 0000000..2eebfa4 --- /dev/null +++ b/docs/superpowers/specs/aifa-phase-b1-report-screenshot-tools-spec.md @@ -0,0 +1,161 @@ +# AIFA Phase B1 — 报告与截图证据拉取(Tool)阶段规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **B1** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(§6.2 Tool、§8.1/§8.3 证据与降级;冲突时以架构为准) + 2. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 **B1** 与依赖关系) + 3. `dt-report-phase-a2-ai-context-builder-spec.md`(`case_context` 中 `reports_url`、截图字段来源与**禁止传 `log_url`**) + 4. `2026-04-08-ai-failure-analysis-tech-selection.md`(`httpx`、`selectolax` 选型) +- **对应分期**:实现计划 **B1** — **`fetch_report_html` + `fetch_screenshot_b64`**:从契约中的 URL 拉取测试报告 HTML 与截图(含索引页解析),截断与条数上限可配置,**不提供**按日志 HTML URL 抓取的能力。 +- **状态**:Draft +- **日期**:2026-04-22 + +--- + +## 0. 文档目的 + +本文档回答:**B1 合并时 AIFA 必须具备哪些证据拉取行为、返回结构、上限、错误语义与验收标准**;不重复架构全文,只固化 **B1 范围内的「必须 / 可选 / 禁止」**。 + +**B1 不包含**(由其他分期负责): + +| 分期 | 内容 | +|------|------| +| **A1–A5** | 服务骨架、payload、SSE、写库、限流(已交付或独立 spec) | +| **B2** | Plan → Act → Synthesize 主循环与 skill 编排 | +| **B3** | 与 B1 重叠的「索引页解析细节」若需按现网 DOM 大量迭代,可在 B3 细化;B1 须先交付**可测试的最小可用**解析与降级 | +| **B4** | 成功 batch、URL 替换、多图对比业务规则 | +| **B5** | CodeHub list_commits / diff | + +--- + +## 1. 职责边界 + +### 1.1 必须满足 + +- **输入来源**:证据 URL 仅来自请求体中的 `case_context`(及可选预填的 `screenshot_urls` / `success_screenshot_urls` 等),与 **dt-report `ai_context_builder`** 输出一致;**不得**要求 AIFA 读 MySQL。 +- **两个 Tool(函数级契约)**: + - `fetch_report_html(reports_url, max_chars=...)`:拉取测试报告 HTML,抽取可用文本或结构化片段,**截断**后返回。 + - `fetch_screenshot_b64(screenshot_url, max_bytes=...)`:拉取单张 `image/*`,或配合上层对**同一 URL** 先识别为 HTML 索引页再解析子链(见 §3.2)。 +- **全部 async**:对外暴露为 `async def`,内部使用共享 `httpx.AsyncClient`(与架构、技术选型一致)。 +- **全部可配置上限**:`max_chars`、`max_bytes`、索引页解析出的**最大图片张数**、各类 **HTTP 超时**须在实现中固定为常量或 env,并文档化默认值。 +- **结构化错误**:网络错误、非预期内容类型、解析失败等**不抛未捕获异常**致整单 500;返回包含 `error` / `detail`(或等价)的 dict,供后续 skill 写入 `data_gaps`。 +- **不提供日志 HTML 抓取**:契约**不传 `log_url`**;本阶段**不**实现「按整页日志 HTML URL 抓取」的 Tool。 + +### 1.2 明确禁止 + +- **禁止**在 Tool 内隐式拼接或猜测 `log_url`、或从 dt-report 以外的配置「补」日志 URL。 +- **禁止**将完整 HTML 原文、完整 base64 图片写入**应用日志**(仅允许长度、hash、状态码等摘要,见 §6)。 +- **禁止**无超时、无大小上限的 GET;禁止跟随重定向到任意外网(见 §5)。 + +### 1.3 可选(B1 允许分步) + +- **DOM/选择器**:具体 `selectolax` 选择器与现网报告/索引页结构绑定,可在初版用**保守策略**(例如先提 `body` 文本再截断),再在 B3 收紧为「只提错误区域」。 +- **dt-report 预填直链**:若 `screenshot_urls[]` 已非空,AIFA **可优先**使用该列表,减少对索引页的一次请求(与架构 §4.1 一致)。 + +--- + +## 2. 与 `case_context` 的字段关系(语义) + +表字段以 `backend/models/pipeline_history.py` 为准;契约命名以架构 §4.1 为准。 + +| 契约字段 | 典型来源 | B1 使用方式 | +|----------|----------|-------------| +| `reports_url` | `pipeline_history.reports_url` | `fetch_report_html` 唯一报告入口 | +| `screenshot_index_url` | `pipeline_history.screenshot_url` 映射 | 单张直链 **或** 目录/索引页 URL | +| `screenshot_urls` | 可选预填 | 若存在且非空,**优先**用于多图拉取 | +| `success_*` | B4 范围 | B1 工具实现应**可复用**同一套 fetch/parse 逻辑;是否在本期接 `follow_up` 由 B2 决定 | + +--- + +## 3. Tool 行为细则 + +### 3.1 `fetch_report_html` + +| 项 | 要求 | +|----|------| +| 方法 | `GET`,`httpx` | +| 期望 `Content-Type` | `text/html` 为主;非 HTML 可返回结构化 `error`,不崩溃 | +| 解析 | 使用 `selectolax`(或技术选型锁定方案)提取正文或关键区域;**必须**在 `max_chars` 处截断并标记 `truncated` | +| 返回(成功示意) | 至少包含:`text`(或等价)、`truncated`、`content_length`;字段名以实现为准,须稳定 | +| 返回(失败) | `error`、`detail`,**不 raise** | + +### 3.2 `fetch_screenshot_b64` + +| 项 | 要求 | +|----|------| +| 单 URL 首次 GET | 若 `Content-Type` 为 `image/*`:读 body,校验 `max_bytes`,返回 base64(或等价)与 `mime` | +| 若为 `text/html` | 视为**索引页**:解析出图片 URL 列表(规则在实现中固定并配**单测 fixture**),再对子 URL 循环拉取;循环须受 **最大张数 N** 约束 | +| 张数策略 | 超出 N 时策略须固定(例如「前 N-1 + 最后 1 张」),并在返回或 `data_gaps` 可解释 | +| 返回(成功) | 单张:`base64` / `mime` / `size_bytes` 等;多张时由上层聚合或返回列表,**必须**有统一 schema 文档 | +| 返回(失败) | 结构化 `error`,单张失败可跳过该张并继续(由调用方 skill 策略决定,B1 提供原子能力即可) | + +### 3.3 超时与大小(建议默认值,可在实现中覆盖) + +与架构 §8.3 方向一致,建议在 B1 中**写死初值**并在 env 中可选覆盖: + +- 报告 HTML:connect / read 超时(例如 connect 3s、read 10s 量级) +- 单图:connect / read 超时 + `max_bytes`(例如 2MB 量级) +- `max_chars`:例如 20000(与架构示例同量级) + +--- + +## 4. 安全(SSRF 与 URL 约束) + +- 对即将请求的 URL 做**允许规则**:例如仅允许特定 host 前缀、或内网域名清单(与运维/网络环境一致),**禁止**任意公网 SSRF。 +- **禁止**默认 `follow_redirects=True` 且无白名单;若开启重定向,须限制次数与目标 host。 +- URL 长度上限,防止异常输入。 + +--- + +## 5. 可观测性与日志 + +- 每次 Tool 调用建议打 **INFO** 级摘要日志:`request_id` / `session_id`(若可传)/ `tool_name` / `elapsed_ms` / `input_url` 的**脱敏或 host+path 截断** / 输出大小。 +- **禁止**在日志中输出:完整 HTML、完整 base64、完整 URL 中的敏感 query(若存在)。 + +--- + +## 6. 验收标准(B1 DoD) + +以下全部满足,视为 **B1 完成**: + +1. **两个 Tool** 均以 `async` 实现,返回结构可被单元测试断言(成功 / 失败路径)。 +2. **`fetch_report_html`**:对合法小 HTML 能返回非空 `text`;超大内容在 `max_chars` 处截断且 `truncated=true`。 +3. **`fetch_screenshot_b64`**:对 `image/*` 直链能返回合理 payload;对索引页 HTML 能解析出至少 0 条图片 URL 且不崩(有 fixture)。 +4. **错误语义**:4xx/5xx/超时返回结构化错误,不导致 `/v1/analyze` 未处理异常 500(除非上层另有约定)。 +5. **无 `log_url` Tool**,且代码路径中不引入日志 HTML 抓取。 +6. **单测覆盖**:关键路径(含截断、索引页、失败)有自动化测试;可选 pytest-httpx mock。 + +--- + +## 7. 推荐测试清单 + +| 用例 | 输入场景 | 期望 | +|------|----------|------| +| 报告小页 | 短 HTML | 有 `text`,未截断或 `truncated` 为 false | +| 报告巨大 | 超过 `max_chars` | 截断 + `truncated` | +| 报告 404/5xx | mock | 结构化 `error` | +| 截图直链 | `image/png` | 有 base64 或等价字段,≤ `max_bytes` | +| 截图索引 | 含多张 `img` 的 HTML | 解析出 ≤N 张;超出策略符合 §3.2 | +| 非预期类型 | `application/json` | 不崩溃,结构化错误 | +| URL 非白名单 | 恶意 host | 拒绝请求或结构化错误(依 §4 实现) | + +--- + +## 8. 与相邻分期衔接 + +- **对 A2**:仅消费已存在的 `reports_url` / 截图相关字段;**不**改 dt-report 表结构。 +- **对 B2**:B2 的 Plan/Act 将调用本阶段 Tool,不在 B1 实现完整 Agent。 +- **对 B4/B5**:成功侧 URL 与 CodeHub 仍按实现计划与架构各自分期交付。 + +--- + +## 9. 维护约定 + +- 若架构 §6.2 / §8.3 调整上限或 Tool 数量:先改架构 SSOT,再同步本文件与实现。 +- 索引页 DOM 与现网变更时:优先补**回归 fixture** 再改选择器,避免线上静默质量下降。 + +### 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-22 | 初稿:B1 范围、`fetch_report_html` / `fetch_screenshot_b64`、边界、安全、DoD 与测试清单 | diff --git a/docs/superpowers/specs/aifa-phase-b2-agent-three-stage-spec.md b/docs/superpowers/specs/aifa-phase-b2-agent-three-stage-spec.md new file mode 100644 index 0000000..8c84768 --- /dev/null +++ b/docs/superpowers/specs/aifa-phase-b2-agent-three-stage-spec.md @@ -0,0 +1,196 @@ +# AIFA Phase B2 — Agent 三阶段编排(Plan / Act / Synthesize)规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **B2** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(§5 Agent 状态机、§6 Skill×Tool、§10 失败语义;冲突时以架构为准) + 2. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 **B2** 与依赖关系) + 3. `aifa-phase-b1-report-screenshot-tools-spec.md`(B1 Tool 能力与错误契约) + 4. `aifa-phase-a3-sse-report-contract-spec.md`(SSE 进度与 report 契约基线) +- **对应分期**:实现计划 **B2** — **Agent 三阶段主循环**:`Plan -> Act -> Synthesize`,并支持 `follow_up` 的 session 复用。 +- **状态**:Draft +- **日期**:2026-04-22 + +--- + +## 0. 文档目的 + +本文档回答:**B2 合并时 Agent 必须具备哪些编排行为、阶段边界、输入输出约束、失败降级与验收标准**;不重复架构全文,只固化 **B2 范围内的「必须 / 可选 / 禁止」**。 + +**B2 不包含**(由其他分期负责): + +| 分期 | 内容 | +|------|------| +| **B1** | `fetch_report_html` / `fetch_screenshot_b64` 具体拉取与索引页解析细节 | +| **B3** | 截图/报告 URL 解析策略的进一步强化与现网 DOM 大规模迭代 | +| **B4** | 成功 batch URL 替换、多图对比业务规则细化 | +| **B5** | CodeHub 调用能力完整接入与质量打磨 | +| **C2** | 追问 UI 与 dt-report 侧会话体验完善(B2 仅定义 AIFA 语义) | + +--- + +## 1. 职责边界 + +### 1.1 必须满足 + +- **固定三阶段状态机**:一次 `mode=initial` 请求必须按 `Plan -> Act -> Synthesize` 执行,阶段顺序不可交换。 +- **Plan 受限选择**:Plan 阶段只能从预定义 skill 集合中选取与排序(如 `history_skill`、`report_analysis_skill`、`screenshot_skill`、`code_blame_skill`),输出必须是可校验 JSON。 +- **Act 顺序执行**:Act 按 `skill_plan` 顺序执行各 skill;每个 skill 只能调用自身白名单 tool。 +- **Skill 隔离**:skill 间仅传递结构化摘要,不传 raw tool output。 +- **Synthesize 只看摘要**:最终报告合成输入仅为阶段摘要与必要元信息,不直接拼接原始 HTML、原始 diff、原始 base64。 +- **follow_up 语义**:`mode=follow_up` 默认跳过 Plan/Act,直接用 session 中缓存的中间结果执行 Synthesize 变体。 +- **可观测**:SSE `progress` 至少覆盖三阶段开始/结束(或等价状态),并写入 `stage_timeline`。 +- **失败可降级**:单个 skill 或 tool 失败不应导致整单 500;若仍有可用证据,返回 `status="partial"` 并填充 `data_gaps`。 + +### 1.2 明确禁止 + +- **禁止**让 Plan 返回未注册 skill 名称并被执行。 +- **禁止**在 Synthesize 阶段绕过摘要隔离,直接读取 raw tool 大文本或大图。 +- **禁止**在 `follow_up` 默认路径中无条件再次触发全部 tool 调用。 +- **禁止**未做上限控制地拼接各 skill 输出到最终 prompt(必须有硬上限或截断策略)。 +- **禁止**因单一外部依赖短时失败而直接将 HTTP 200 流式请求升级为未处理 500(配置错误 fail-loud 场景除外)。 + +### 1.3 可选(B2 允许分步) + +- Plan 的输出可先以最小字段实现(如仅 `skills` + `reason`),后续再扩展权重、优先级解释。 +- 可先串行执行 skill;并发调度留待后续性能迭代。 +- `follow_up` 下“必须补查时重走 Plan”可先保守实现为“仅提示 data_gaps,不自动补查”。 + +--- + +## 2. 状态机与阶段出口条件 + +## 2.1 `mode=initial` + +1. **Plan** + - 输入:请求上下文(`case_context`、`recent_executions`、`repo_hint`、可选 `follow_up_message` 为空)。 + - 输出:`skill_plan`(有序数组)+ 可选解释字段。 + - 出口条件:JSON 校验通过;否则走兜底计划(见 §5)。 + +2. **Act** + - 输入:`skill_plan` + 请求上下文。 + - 执行:逐个 skill,产出 `skill_summaries[skill_name]`。 + - 出口条件:全部 skill 完成,或达到可合成最小证据门槛(其余记 `data_gaps`)。 + +3. **Synthesize** + - 输入:`skill_summaries` + 关键元信息(`session_id`、时间线、data_gaps)。 + - 输出:最终 `report`。 + - 出口条件:`report` 结构校验通过,推送 `event: report` 结束。 + +## 2.2 `mode=follow_up` + +- 默认路径:跳过 Plan 与 Act,直接读取 `session_id` 对应的 `skill_summaries` 执行 Synthesize 变体。 +- 若 session 缺失或过期:返回结构化错误(建议 `event: error` + 可读 message),不隐式退化为全量重跑。 +- 是否允许“必须补查后重走 Plan”:B2 可选;若暂不支持,必须在 `data_gaps`/错误信息中明确说明。 + +--- + +## 3. Skill 与 Tool 编排约束 + +### 3.1 Skill 清单(B2 生效范围) + +| Skill | 允许 Tool | 产出摘要最小字段 | +|------|-----------|------------------| +| `history_skill` | 无 | `pattern`, `last_pass_batch` | +| `report_analysis_skill` | `fetch_report_html` | `error_lines[]`, `stack_summary`, `keywords[]` | +| `screenshot_skill` | `fetch_screenshot_b64` | `ui_state`, `visible_error_text`, `compare_notes[]` | +| `code_blame_skill` | `codehub_list_commits`, `codehub_get_commit_diff` | `suspect_patches[]` | +| `synthesis_skill` | 无 | 最终 `report` | + +> 注:B2 允许在实现初期按能力开关临时禁用某些 skill,但需保证 Plan 不会选中被禁用项,或在 Act 中可预期降级并写入 `data_gaps`。 + +### 3.2 编排规则 + +- Plan 只决定“执行哪些 skill、顺序如何”,不执行 tool。 +- Act 仅负责执行 skill 与收集摘要,不生成最终面向用户的完整长报告。 +- Synthesize 不调用 tool,不访问外部数据源。 + +--- + +## 4. Session 与数据模型(B2 最小) + +为支撑 follow_up,AIFA 侧需有最小会话缓存(内存或可替换存储,B2 不强制持久化引擎): + +- `session_id: str` +- `mode: "initial" | "follow_up"` +- `plan: list[str]` +- `skill_summaries: dict[str, dict]` +- `stage_timeline: list[dict]` +- `data_gaps: list[str]` +- `updated_at: int`(unix ts) + +**TTL 建议**:30 分钟(实现可配置)。 +**容量策略**:超限按 LRU 或最旧淘汰(实现固定一种即可)。 + +--- + +## 5. 错误语义与降级 + +### 5.1 分级原则 + +- **Partial(推荐默认)**:某个 skill 失败、某个 tool 超时、某路证据缺失;仍可给出报告。 +- **Error(业务可恢复失败)**:完全缺少可用于合成的摘要,或会话缺失导致 follow_up 无法执行。 +- **Fail-loud(配置类)**:核心配置错误(如内部 token/关键密钥错误)可按架构策略直接失败。 + +### 5.2 阶段级处理建议 + +- Plan JSON 非法:记录 warning,使用兜底 `skill_plan`(如 `["history_skill", "report_analysis_skill", "screenshot_skill"]`,按可用能力裁剪)。 +- Act 单 skill 失败:写 `data_gaps`,继续后续 skill。 +- Synthesize 结构校验失败:尝试一次修复性重试;仍失败则返回 `status="error"` 的结构化结果而非未处理异常。 + +--- + +## 6. SSE 与可观测性(B2 补充要求) + +- `progress` 事件至少包含阶段粒度:`plan_started/plan_done`、`act_started/act_done`、`synthesize_started/synthesize_done`(命名可调整,但语义必须稳定)。 +- `report.stage_timeline` 需记录阶段名与耗时(毫秒)。 +- 日志最小字段:`request_id`、`session_id`、`stage`、`skill`(若适用)、`elapsed_ms`、`status`。 +- 禁止记录 raw HTML、raw base64、完整敏感 URL query。 + +--- + +## 7. 验收标准(B2 DoD) + +以下全部满足,视为 **B2 完成**: + +1. `mode=initial` 可稳定跑通三阶段,并返回包含 `stage_timeline` 的 `report`。 +2. Plan 输出严格受 JSON schema 校验;非法输出有可测试兜底策略。 +3. Act 期间 skill 间仅传结构化摘要;代码路径可证明未拼接 raw tool output 到跨 skill 上下文。 +4. Synthesize 输入有长度上限(截断或硬限制),避免 prompt 失控。 +5. `mode=follow_up` 能复用 session 中间结果生成追问回答;session 不存在时返回结构化错误。 +6. 任一 skill/tool 失败时,若仍可合成,返回 `status="partial"` + `data_gaps`,不抛未处理 500。 +7. 自动化测试覆盖至少:Plan 非法 JSON、单 skill 失败降级、follow_up 命中/未命中 session、Synthesize 输入上限策略。 + +--- + +## 8. 推荐测试清单 + +| 用例 | 输入场景 | 期望 | +|------|----------|------| +| 初始请求主路径 | `mode=initial` + 正常 payload | 阶段顺序正确,最终 `report.status in {ok, partial}` | +| Plan 非法输出 | mock LLM 返回非 JSON | 触发兜底计划,流程继续 | +| Skill 局部失败 | `fetch_report_html` 超时 | 仍返回 `partial`,`data_gaps` 含报告缺失 | +| Skill 隔离校验 | 构造大 raw 输出 | Synthesize 输入仅摘要,长度受控 | +| follow_up 命中 | 有 `session_id` 且缓存可用 | 跳过 Plan/Act,直接生成追问结果 | +| follow_up 未命中 | 过期或不存在 session | 结构化错误,不 silent full rerun | +| timeline 完整性 | 全流程 | `stage_timeline` 至少含三阶段条目 | + +--- + +## 9. 与相邻分期衔接 + +- **对 B1**:B2 直接消费 B1 的 tool 契约与结构化错误。 +- **对 B3/B4**:截图索引解析与对比规则增强后,不改 B2 三阶段主框架,仅新增 skill 内部能力。 +- **对 C2**:前端追问体验、会话展示可复用 B2 的 `session_id` 与 follow_up 语义。 + +--- + +## 10. 维护约定 + +- 若架构 §5/§6 对阶段边界、skill 清单、follow_up 规则有变更:先更新架构,再同步本文件与实现。 +- 若实现引入新 skill:必须同步更新「Plan 可选集合」「Skill×Tool 矩阵」「DoD 与测试清单」。 + +### 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-22 | 初稿:B2 三阶段状态机、skill 编排约束、follow_up 语义、错误降级与 DoD | diff --git a/docs/superpowers/specs/aifa-phase-b3-url-resolution-spec.md b/docs/superpowers/specs/aifa-phase-b3-url-resolution-spec.md new file mode 100644 index 0000000..15b80e9 --- /dev/null +++ b/docs/superpowers/specs/aifa-phase-b3-url-resolution-spec.md @@ -0,0 +1,181 @@ +# AIFA Phase B3 — 报告/截图 URL 解析与归一化规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **B3** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(§4.1 case_context、§8.3 URL 与证据拉取约束;冲突时以架构为准) + 2. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 **B3** 与依赖关系) + 3. `aifa-phase-b1-report-screenshot-tools-spec.md`(B1 Tool 能力与最小解析基线) + 4. `aifa-phase-b2-agent-three-stage-spec.md`(B2 编排与 follow_up 语义) +- **对应分期**:实现计划 **B3** — **报告/截图 URL 解析与归一化**:将 `case_context` 中的 URL 字段转化为稳定、可拉取、可审计的候选列表,作为 B1 Tool 的上游输入。 +- **状态**:Draft +- **日期**:2026-04-22 + +--- + +## 0. 文档目的 + +本文档回答:**B3 合并时 URL 相关能力必须达到什么“稳定可用”标准**,包括字段优先级、索引页链接解析、相对路径归一化、去重排序、白名单过滤、错误语义与验收标准。 + +**B3 不包含**(由其他分期负责): + +| 分期 | 内容 | +|------|------| +| **B1** | 报告 HTML 与图片二进制拉取的原子 Tool 实现(`fetch_report_html` / `fetch_screenshot_b64`) | +| **B2** | Plan -> Act -> Synthesize 编排与 session follow_up 主循环 | +| **B4** | 成功 batch URL 替换、多图对比业务规则 | +| **B5** | CodeHub 证据能力 | + +--- + +## 1. 职责边界 + +### 1.1 必须满足 + +- **输入仅来自契约字段**:`reports_url`、`screenshot_index_url`、`screenshot_urls[]`(及后续 success 侧同构字段),不读取数据库、不推测外部隐式配置。 +- **统一 URL 解析层**:新增(或固化)URL 归一化函数层,输出 `normalized_report_url` 与 `normalized_screenshot_urls[]`(字段名可调整,但语义必须稳定)。 +- **明确优先级**: + 1. 若 `screenshot_urls[]` 非空,优先视为已解析直链集合; + 2. 否则使用 `screenshot_index_url` 进入索引页解析; + 3. 两者都缺失则返回结构化缺失信息,由上层填充 `data_gaps`。 +- **索引页解析结果可控**:支持相对路径转绝对 URL、过滤非图片链接、去重、上限裁剪、稳定排序。 +- **全部 async + 可测试**:URL 解析流程可通过 fixture 做自动化回归,不依赖线上真实地址。 +- **错误可降级**:某一路 URL 解析失败不应导致整单未处理 500;应返回结构化错误并允许其他证据继续。 + +### 1.2 明确禁止 + +- **禁止**在 B3 引入“按日志 HTML URL 抓取”能力(契约仍不传 `log_url`)。 +- **禁止**无白名单约束地接受任意 host 并发起请求。 +- **禁止**将完整敏感 query 参数写入日志。 +- **禁止**在 URL 解析层耦合业务判责逻辑(如 flaky/spec_change 判定);该类规则属于 B4。 + +### 1.3 可选(B3 允许分步) + +- 初版可先实现“常见索引页结构”选择器,不要求一次覆盖所有历史 DOM 变体,但必须有失败可解释输出。 +- 可先对截图 URL 应用归一化;报告 URL 的强化校验(如 content-type 预检)可在同阶段后续小迭代补齐。 + +--- + +## 2. 输入字段与解析优先级 + +| 字段 | 含义 | B3 行为 | +|------|------|---------| +| `reports_url` | 失败用例报告页 URL | 做合法性校验与归一化,输出单一可拉取 URL | +| `screenshot_urls[]` | 预填截图直链列表 | 逐条归一化 + 去重 + 白名单过滤,产出候选列表 | +| `screenshot_index_url` | 截图目录页/索引页 URL | 当直链列表为空时,解析 HTML 提取图片链接 | + +**优先级规则(必须一致)**: + +1. 先消费 `screenshot_urls[]`(若非空)。 +2. 若为空且有 `screenshot_index_url`,进入索引页提链。 +3. 两者都不可用时返回空列表与 `missing_screenshot_urls` 类错误标签。 + +--- + +## 3. URL 归一化规则 + +## 3.1 基础校验 + +- 仅允许 `http` / `https` scheme。 +- URL 长度有上限(默认值可配置,例如 2048)。 +- 非法 URL(无主机、非法字符、空白)直接结构化拒绝。 + +## 3.2 相对路径转绝对路径 + +针对索引页提取出的链接,按以下顺序归一化: + +1. 绝对 URL(`http(s)://...`)直接保留; +2. 协议相对 URL(`//host/path`)继承索引页 scheme; +3. 根相对路径(`/a/b.png`)拼接索引页 `scheme://host`; +4. 相对路径(`../img/x.png`、`./x.png`)使用标准 URL join 归一化。 + +## 3.3 过滤与去重 + +- 只保留图片候选(后缀匹配或后续 HEAD/GET content-type 校验,策略固定一种即可)。 +- 统一去掉片段(`#...`)后去重,保持首次出现顺序。 +- 白名单过滤应在最终请求前再次执行(双保险)。 + +## 3.4 截断策略 + +- 候选链接数量必须受 `max_screenshot_candidates` 限制。 +- 超限时使用固定策略(建议“前 N-1 + 最后 1”),并在返回元信息中标记 `truncated=true`。 + +--- + +## 4. 解析产物契约(B3 最小) + +URL 解析层至少输出以下结构(命名可调整): + +- `report_url: Optional[str]` +- `screenshot_urls: list[str]` +- `url_resolution_meta: { source, input_count, output_count, truncated, warnings[] }` +- `errors: list[{ code, message, field }]` + +其中: + +- `source` 取值建议:`prefilled_urls` / `index_page` / `none` +- `warnings[]` 用于非致命问题(例如“3 条 URL 非白名单已跳过”) +- `errors[]` 用于致命缺失或非法输入(例如 `invalid_reports_url`) + +--- + +## 5. 安全与可观测性 + +### 5.1 安全要求 + +- URL 请求前必须进行 host allowlist 校验。 +- 若允许重定向,必须限制次数,并对跳转目标再次做 allowlist 校验。 +- 禁止访问本地环回、链路本地与保留网段(按运行环境策略实现)。 + +### 5.2 日志要求 + +- 记录摘要字段:`request_id`、`session_id`、`resolver_stage`、`input_count`、`output_count`、`elapsed_ms`。 +- URL 仅记录 `host + path` 或脱敏后形式,不记录完整敏感 query。 +- 解析失败需有可检索错误码,便于定位 fixture 漏覆盖与线上 DOM 变更。 + +--- + +## 6. 验收标准(B3 DoD) + +以下全部满足,视为 **B3 完成**: + +1. 当 `screenshot_urls[]` 非空时,系统可稳定输出去重、过滤后的截图 URL 列表,并跳过索引页解析。 +2. 当仅有 `screenshot_index_url` 时,系统可从 fixture 索引页中提取图片 URL,并正确处理绝对/相对路径。 +3. URL 归一化具备白名单与合法性校验;非法 URL 不触发未处理异常。 +4. 候选列表超限时,裁剪策略稳定且可被单测断言。 +5. 解析结果包含来源标记与 warning/error 元信息,供 B2 `data_gaps` 与 `stage_timeline` 使用。 +6. 自动化测试至少覆盖:预填直链、索引页相对路径、非图片链接过滤、重复链接去重、白名单拒绝、超限截断。 + +--- + +## 7. 推荐测试清单 + +| 用例 | 输入场景 | 期望 | +|------|----------|------| +| 预填直链优先 | `screenshot_urls` 有值,`screenshot_index_url` 也存在 | 使用直链列表,不触发索引页解析 | +| 索引页相对路径 | HTML 含 `../`, `./`, `/` 三类链接 | 全部归一化为绝对 URL | +| 非图片链接混入 | 索引页包含 `.html`、`.js`、空链接 | 非图片被过滤,流程不崩溃 | +| 重复链接 | 同一图片多次出现(含 hash 差异) | 去重后数量正确 | +| 白名单拒绝 | 链接 host 不在 allowlist | 结构化错误/警告,且不发起实际拉取 | +| 超限裁剪 | 解析得到数量 > `max_screenshot_candidates` | 按固定策略裁剪并标记 `truncated` | +| 报告 URL 非法 | `reports_url` 格式错误 | 返回 `invalid_reports_url` 类错误 | + +--- + +## 8. 与相邻分期衔接 + +- **对 B1**:B3 向 B1 Tool 提供“更干净”的 URL 输入,减少 Tool 内分支复杂度。 +- **对 B2**:B2 无需感知解析细节,仅消费 `url_resolution_meta` 与结构化错误。 +- **对 B4**:B4 在 B3 归一化能力之上实现成功侧 URL 替换与多图对比规则。 + +--- + +## 9. 维护约定 + +- 若架构中 URL 字段命名或安全约束调整:先更新架构,再同步本文件与实现。 +- 每次新增索引页解析规则,必须同步补 fixture 与回归测试,避免线上静默退化。 + +### 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-22 | 初稿:B3 URL 解析与归一化边界、规则、DoD 与测试清单 | diff --git a/docs/superpowers/specs/aifa-phase-b4-success-batch-compare-spec.md b/docs/superpowers/specs/aifa-phase-b4-success-batch-compare-spec.md new file mode 100644 index 0000000..b046ff6 --- /dev/null +++ b/docs/superpowers/specs/aifa-phase-b4-success-batch-compare-spec.md @@ -0,0 +1,205 @@ +# AIFA Phase B4 — 成功批次 URL 替换与多图对比规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **B4** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(§1.4、§4.3、§8.3 对比与降级规则;冲突时以架构为准) + 2. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 **B4** 与依赖关系) + 3. `aifa-phase-b3-url-resolution-spec.md`(B3 URL 归一化与解析产物) + 4. `aifa-phase-b2-agent-three-stage-spec.md`(B2 三阶段编排与 `data_gaps` 语义) + 5. `dt-report-phase-a2-ai-context-builder-spec.md`(`last_success_batch` 与成功侧 URL 字段来源) +- **对应分期**:实现计划 **B4** — **成功批次 + URL 替换 + 多图对比**:在 B3 URL 能力基础上引入失败/成功截图集对比,并将结果纳入 `spec_change` / `flaky` 判定与降级。 +- **状态**:Draft +- **日期**:2026-04-22 + +--- + +## 0. 文档目的 + +本文档回答:**B4 合并时“成功侧证据对比”必须具备哪些输入约束、URL 替换策略、对比产物、归类硬规则与验收标准**。 +核心目标是把「是否可以判定 `spec_change` / `flaky`」从“模型自由发挥”收敛为“有证据可追溯的工程规则”。 + +**B4 不包含**(由其他分期负责): + +| 分期 | 内容 | +|------|------| +| **B1** | 报告/截图拉取原子 Tool(fetch)实现 | +| **B2** | 三阶段主循环、session 与 follow_up 基础语义 | +| **B3** | URL 基础解析与归一化(失败侧) | +| **B5** | CodeHub commits/diff 证据增强 | + +--- + +## 1. 职责边界 + +### 1.1 必须满足 + +- **成功侧证据输入**:消费 `last_success_batch`、`success_screenshot_index_url`、`success_screenshot_urls[]`(以及需要时的成功侧报告 URL 字段),不直接查库。 +- **URL 替换路径可落地**:当上游未提供成功侧直链列表时,支持按约定对失败侧 URL 做“仅 batch 段替换”得到成功侧候选 URL(替换规则见 §3)。 +- **双集合对比**:`screenshot_skill`(或等价模块)能够基于“失败截图集 vs 成功截图集”生成结构化对比摘要。 +- **硬规则落地**:当成功侧截图证据缺失或对比证据不足时,系统**禁止强判** `spec_change` / `flaky`,必须降级并写 `data_gaps`。 +- **可解释输出**:对比结果至少包含匹配数量、差异摘要、证据不足原因,供 Synthesize 阶段消费。 +- **部分失败可降级**:成功侧某些图片拉取失败不应导致整单 500;允许 `partial` 返回。 + +### 1.2 明确禁止 + +- **禁止**在无成功侧可用截图证据时输出 `spec_change` / `flaky` 最终分类。 +- **禁止**在 URL 替换逻辑中引入宽松“猜测替换”(例如多段模糊替换导致跨环境误命中)。 +- **禁止**将原始大图 base64、完整 HTML 写入日志。 +- **禁止**把“是否为 bug/env”完全依赖视觉对比;B4 仅增强 `spec_change/flaky` 判定可靠性。 + +### 1.3 可选(B4 允许分步) + +- 初版可采用“LLM 视觉对比 + 规则后处理”的混合策略;更复杂图像相似度算法可后续迭代。 +- 失败/成功多图匹配可先按索引顺序或简单规则配对,后续再引入更稳健匹配策略。 + +--- + +## 2. 输入字段与优先级 + +| 字段 | 来源 | B4 用途 | +|------|------|---------| +| `batch` | 失败记录上下文 | URL 替换中的“失败批次”基准 | +| `last_success_batch` | dt-report 计算 | URL 替换目标批次 | +| `screenshot_urls[]` / `screenshot_index_url` | 失败侧证据入口 | 构建失败截图集合 | +| `success_screenshot_urls[]` / `success_screenshot_index_url` | 成功侧证据入口 | 构建成功截图集合 | + +**成功侧获取优先级(必须固定)**: + +1. 若 `success_screenshot_urls[]` 非空,直接使用; +2. 否则使用 `success_screenshot_index_url` 解析; +3. 若仍缺失,且 `last_success_batch` 与失败侧 URL 可用,尝试 batch 替换生成成功侧入口; +4. 仍不可得则标记 `success_evidence_missing` 并触发强判降级。 + +--- + +## 3. 成功批次 URL 替换规则(B4 核心) + +## 3.1 触发条件 + +- 上游未给出可用 `success_screenshot_urls[]` / `success_screenshot_index_url`; +- 同时具备 `last_success_batch` 与失败侧截图入口 URL。 + +## 3.2 替换约束 + +- 仅允许替换 URL 中明确标识为“批次段”的子串(例如路径中的 `batch_xxx` 段)。 +- 替换前后必须保持: + - scheme / host / 端口不变; + - 路径结构不变(仅批次段变化); + - query 参数仅在必要时保留,不新增敏感参数。 +- 替换后 URL 仍需通过 B3 白名单与合法性校验。 + +## 3.3 失败处理 + +- 无法定位批次段:记录 `batch_replace_not_applicable`; +- 替换后 URL 非法或不可访问:记录 `batch_replace_invalid_target`; +- 以上均不应抛未处理异常,统一进入 `data_gaps` 与 `partial` 语义。 + +--- + +## 4. 多图对比产物契约(B4 最小) + +对比阶段至少输出(字段名可调整): + +- `compare_summary: str`(一段简要对比结论) +- `compare_notes: list[str]`(差异点列表) +- `failed_image_count: int` +- `success_image_count: int` +- `paired_count: int` +- `unpaired_failed_count: int` +- `unpaired_success_count: int` +- `evidence_sufficiency: "enough" | "insufficient" | "missing"` + +其中: + +- `evidence_sufficiency` 由规则层判定,不完全依赖 LLM 文本。 +- `insufficient/missing` 必须附带可读原因(例如“成功侧仅 1 张且不可配对”)。 + +--- + +## 5. 分类硬规则与降级 + +与架构 §4.3/§8.3 对齐,B4 必须实现以下后处理约束: + +1. **无成功侧证据**(`evidence_sufficiency=missing`): + - 最终 `failure_category` 不得为 `spec_change` / `flaky`; + - 若模型输出上述分类,强制降级为 `unknown`(或实现约定的次优类别); + - `data_gaps` 追加“缺少成功侧截图对比证据”说明。 +2. **证据不足**(`insufficient`): + - 同样禁止强判 `spec_change` / `flaky`; + - 可保留 `bug` / `env` / `unknown` 候选。 +3. **证据充分**(`enough`): + - 才允许 `spec_change` / `flaky` 进入最终候选; + - 仍需在 `evidence[]` 中保留可追溯对比摘要。 + +--- + +## 6. 与 B2/B3 的集成要求 + +- **对 B3**:复用 B3 URL 解析与校验能力;B4 不重复实现 URL 基础逻辑。 +- **对 B2 Act**:在 `screenshot_skill` 增加“成功侧集合构建 + 对比摘要”子阶段。 +- **对 B2 Synthesize**:输入新增对比结构化摘要,而不是原始图片内容。 +- **对 follow_up**:默认复用已缓存的对比摘要;仅在用户明确要求重比对时才重拉取(可后续增强)。 + +--- + +## 7. 安全与可观测性 + +### 7.1 安全要求 + +- 成功侧替换生成的 URL 必须再次执行 allowlist 校验。 +- 对比流程中的图片拉取沿用大小上限与超时,不得无限扩张。 + +### 7.2 日志要求 + +- 记录摘要:`session_id`、`failed_image_count`、`success_image_count`、`paired_count`、`evidence_sufficiency`、`elapsed_ms`。 +- 记录 URL 时仅保留脱敏信息(host + 截断 path)。 +- 不记录原始图像 base64、不记录完整敏感 query。 + +--- + +## 8. 验收标准(B4 DoD) + +以下全部满足,视为 **B4 完成**: + +1. 成功侧 URL 获取遵循优先级:直链 > 索引页 > batch 替换。 +2. batch 替换逻辑具备明确约束与错误码,异常场景可测试。 +3. `screenshot_skill` 可输出失败/成功双集合的结构化对比摘要。 +4. 当成功侧证据 `missing/insufficient` 时,`spec_change` / `flaky` 被规则层阻断。 +5. 成功侧证据充分时,允许输出 `spec_change` / `flaky`,且 evidence 可追溯。 +6. 任意单路拉取或对比失败不会导致整单未处理 500,系统可返回 `partial`。 +7. 自动化测试覆盖至少:优先级分支、batch 替换成功/失败、证据不足降级、证据充分放行。 + +--- + +## 9. 推荐测试清单 + +| 用例 | 输入场景 | 期望 | +|------|----------|------| +| 成功直链优先 | `success_screenshot_urls[]` 非空 | 不执行 batch 替换,直接对比 | +| 索引页回退 | 无成功直链,有 `success_screenshot_index_url` | 能解析成功侧图片并参与对比 | +| batch 替换成功 | 无成功侧字段,`last_success_batch` 可替换 | 生成可用成功侧 URL 并完成对比 | +| batch 替换不可用 | URL 无批次段或替换后非法 | 写 `data_gaps`,不崩溃 | +| 证据缺失降级 | 成功侧完全不可用 | 禁止 `spec_change/flaky`,分类降级 | +| 证据不足降级 | 成功侧仅少量且无法配对 | 禁止 `spec_change/flaky`,返回 `partial` | +| 证据充分放行 | 双侧多图可配对且差异明确 | 允许 `spec_change` 或 `flaky` 候选 | + +--- + +## 10. 与相邻分期衔接 + +- **对 B3**:B3 负责“把 URL 变干净”;B4 负责“用成功侧 URL 生成可判定对比证据”。 +- **对 B5**:B5 的代码变更证据可与 B4 对比证据互补,但不替代 B4 的硬规则门槛。 +- **对 C2**:追问时可直接复用 B4 对比摘要,减少重复拉图成本。 + +--- + +## 11. 维护约定 + +- 若架构更新 `spec_change/flaky` 判定门槛,先改架构,再同步本文件与实现。 +- 若线上截图索引结构变化导致对比质量下降,先补 fixture 回归,再调整解析与配对策略。 + +### 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-22 | 初稿:B4 成功批次 URL 替换、多图对比、分类硬规则与 DoD | diff --git a/docs/superpowers/specs/aifa-phase-b5-codehub-spec.md b/docs/superpowers/specs/aifa-phase-b5-codehub-spec.md new file mode 100644 index 0000000..af37e0a --- /dev/null +++ b/docs/superpowers/specs/aifa-phase-b5-codehub-spec.md @@ -0,0 +1,235 @@ +# AIFA Phase B5 — CodeHub 提交与 Diff 证据规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **B5** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(§6.2 Tool、§8.4 CodeHub、§10 失败语义;冲突时以架构为准) + 2. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 **B5** 与依赖关系) + 3. `aifa-phase-b2-agent-three-stage-spec.md`(B2 三阶段编排与摘要隔离) + 4. `dt-report-phase-a2-ai-context-builder-spec.md`(`repo_hint` 来源与映射约定) + 5. `2026-04-08-ai-failure-analysis-tech-selection.md`(CodeRepoClient 抽象、httpx 选型) +- **对应分期**:实现计划 **B5** — **CodeHub 证据链**:交付 `codehub_list_commits` 与 `codehub_get_commit_diff`,为 `code_blame_skill` 产出可追溯 `suspect_patches[]`。 +- **状态**:Draft +- **日期**:2026-04-23 + +--- + +## 0. 文档目的 + +本文档回答:**B5 合并时 CodeHub 能力必须具备哪些输入前提、Tool 行为、筛选与截断策略、失败降级、安全与验收标准**。 +核心目标是让“可疑代码变更”从主观猜测变成可审计证据,不破坏 B2 的摘要隔离与可控成本。 + +**B5 不包含**(由其他分期负责): + +| 分期 | 内容 | +|------|------| +| **A2** | `repo_hint` 生产与映射维护(dt-report 侧) | +| **B1/B3** | 报告/截图 URL 拉取与解析 | +| **B2** | Plan -> Act -> Synthesize 主循环与 session 语义 | +| **B4** | 成功批次替换与多图对比判定规则 | +| **C2** | 追问 UI 与交互体验完善 | + +--- + +## 1. 职责边界 + +### 1.1 必须满足 + +- **输入来源固定**:仓库信息来自请求体 `repo_hint`(`repo_url`、`default_branch`、`path_hints`);AIFA 不推断“模块到仓库”映射。 +- **两个 Tool 交付**: + - `codehub_list_commits(repo_url, branch, since, until, path_filters, limit)` + - `codehub_get_commit_diff(repo_url, sha, max_lines)` +- **技能产物可追溯**:`code_blame_skill` 输出 `suspect_patches[]`,每条至少含 `sha`、`author`、`commit_time`、`why_suspect`(字段名可调整,语义必须稳定)。 +- **成本可控**: + - 提交列表有条数上限(默认 `limit=30`) + - diff 有行数上限(默认 `max_lines=500`) + - 仅对 Top 3-5 条可疑 commit 拉取 diff +- **失败语义明确**: + - 网络故障/超时/5xx:可降级,返回 `partial + data_gaps` + - 时间窗无提交:业务正常结果,不作为错误 + - **401 token 无效:fail-loud(返回 500)** + +### 1.2 明确禁止 + +- **禁止**在 `repo_hint` 缺失时强行猜测仓库地址或跨仓扫描。 +- **禁止**将原始完整 diff 直接透传到 Synthesize 阶段(必须先摘要化)。 +- **禁止**无上限抓取大 diff 或对全部 commit 拉 diff。 +- **禁止**在日志中记录完整 token、完整 diff 原文、完整敏感 URL query。 + +### 1.3 可选(B5 允许分步) + +- 初版可先按时间窗 + path filter 做基础筛选;更复杂“语义相关性排序”可后续迭代。 +- 初版可串行拉 TopN diff;并发优化可后续补充,但需保留全局超时与限并发。 + +--- + +## 2. 输入与默认策略 + +| 输入字段 | 来源 | B5 用途 | +|----------|------|---------| +| `repo_hint.repo_url` | dt-report `module_repo_mapping` | CodeHub 仓库定位 | +| `repo_hint.default_branch` | 同上 | branch 默认值 | +| `repo_hint.path_hints[]` | 同上 | 提交列表路径过滤 | +| `case_context.start_time` / `batch` | 当前失败记录 | `until` 基准时间 | +| `case_context.last_success_batch` | dt-report 计算最近成功批次 | `since` 优先基准时间 | + +**时间窗优先级(必须)**: + +- `branch`: `repo_hint.default_branch`,若为空则回退 `"master"`(或服务配置默认分支) +- `until`: 当前失败批次/开始时间 +- 当 `last_success_batch` 可用时:`since = last_success_batch`(即“最近成功批次 -> 当前失败批次”窗口) +- 当 `last_success_batch` 缺失/非法时:`since = until - 7d`(兜底窗口) +- `path_filters`: `repo_hint.path_hints`(为空时允许无路径过滤) +- `list_limit`: 30 +- `diff_max_lines`: 500 +- `diff_top_n`: 3-5 + +**说明**: + +- 对“长期连续成功后首次失败”的主流场景,优先窗口可显著降低噪声提交数量,提高可疑变更定位精度。 +- 兜底 7 天窗口仅用于成功批次不可得场景,避免因数据缺失导致 CodeHub 完全不可用。 + +--- + +## 3. Tool 契约与行为细则 + +## 3.1 `codehub_list_commits` + +| 项 | 要求 | +|----|------| +| 方法 | `GET`(由 CodeHub API 文档确定 endpoint) | +| 认证 | `AIFA_CODEHUB_TOKEN`(header 名在实现时按网关规范固定) | +| 入参 | `repo_url`, `branch`, `since`, `until`, `path_filters[]`, `limit` | +| 成功返回最小 | `{commits:[{sha, author, time, message, files[]}]}` | +| 失败返回 | 结构化 `{error, detail, status_code?}`(401 见 §5) | +| 排序 | 默认按提交时间倒序(若上游 API 不保证,需本地规范化) | + +**行为要求**: + +- 需要对时间窗参数做合法性校验(`since <= until`)。 +- 当输入包含 `last_success_batch` 且可解析时,必须优先使用“成功 -> 失败”窗口,不得静默改为固定 7 天。 +- `path_filters` 为空时允许全仓时间窗查询,但仍受 `limit` 限制。 +- 返回条目必须可被后续评分逻辑消费,缺失关键字段时要有兜底值或结构化告警。 + +## 3.2 `codehub_get_commit_diff` + +| 项 | 要求 | +|----|------| +| 方法 | `GET`(按 CodeHub API endpoint) | +| 入参 | `repo_url`, `sha`, `max_lines` | +| 成功返回最小 | `{diff, truncated, files_changed}` | +| 截断 | 超过 `max_lines` 必须裁剪并标记 `truncated=true` | +| 失败返回 | 结构化 `{error, detail, status_code?}` | + +**行为要求**: + +- 仅对 `list_commits` 筛出的 TopN 执行,不允许对全量 commit 拉 diff。 +- diff 截断策略固定(例如按行裁剪,保留头部上下文),并可测试断言。 + +--- + +## 4. `code_blame_skill` 产物约束(B5 最小) + +`code_blame_skill` 至少输出: + +- `suspect_patches[]`: + - `sha: str` + - `author: str` + - `commit_time: str` + - `summary: str`(提交信息与关键文件变更摘要) + - `why_suspect: str`(怀疑理由,需引用规则或证据) + - `files_touched: list[str]` + - `diff_excerpt: str`(可选,必须受截断) + - `truncated: bool` +- `codehub_meta`: + - `time_window` + - `list_count` + - `diff_fetched_count` + - `skipped_reason[]`(如“超限未拉取”“diff 拉取失败”) + +**关键约束**: + +- Skill 可读取 raw diff,但输出到 Synthesize 的只能是摘要字段。 +- `why_suspect` 必须可解释,避免仅输出“模型认为可疑”。 + +--- + +## 5. 错误语义与降级 + +| 场景 | 期望行为 | +|------|----------| +| `repo_hint` 缺失或 `repo_url` 为空 | 跳过 code blame,`data_gaps` 记“仓库映射缺失”,可返回 `partial` | +| `last_success_batch` 缺失/非法 | 回退 `since=until-7d`,并在 `codehub_meta` 或 `data_gaps` 标注“已使用兜底时间窗” | +| CodeHub 网络失败/超时/5xx | 跳过该 skill 或部分结果,写 `data_gaps`,不中断整单 | +| 时间窗无提交 | 正常返回空 `suspect_patches[]`,附“该时间窗无新增提交” | +| commit diff 单条失败 | 跳过该条,继续其他 commit,写 `skipped_reason` | +| **CodeHub 401(token 无效)** | **fail-loud**:整单返回 500,提示联系管理员 | + +--- + +## 6. 安全与可观测性 + +### 6.1 安全要求 + +- `repo_url` 在请求前需校验域名白名单(必须匹配 `AIFA_CODEHUB_BASE_URL` 域)。 +- 禁止拼接任意用户输入形成未校验 CodeHub API URL。 +- Token 仅从环境变量读取,不落盘不回显。 + +### 6.2 日志要求 + +- INFO 摘要日志建议字段:`request_id`、`session_id`、`skill=code_blame`、`elapsed_ms`、`list_count`、`diff_fetched_count`、`status`。 +- 仅记录 `diff_hash` + `lines`,不记录 raw diff。 +- 401 需有可检索错误码,便于告警与运维排查。 + +--- + +## 7. 验收标准(B5 DoD) + +以下全部满足,视为 **B5 完成**: + +1. `codehub_list_commits` 与 `codehub_get_commit_diff` 可稳定返回结构化成功/失败结果。 +2. `code_blame_skill` 能按“成功批次 -> 失败批次优先,缺失再回退 7 天”的时间窗 + path filter 产出可追溯 `suspect_patches[]`。 +3. diff 抓取严格受 TopN 与 `max_lines` 双上限约束,且有单测覆盖。 +4. `repo_hint` 缺失、网络失败、无提交三类场景均可降级为 `partial`(或空证据)而非未处理 500。 +5. CodeHub 401 明确触发 fail-loud(HTTP 500),并有中文可读提示。 +6. Synthesize 输入不包含 raw diff,仅包含 B5 摘要产物。 +7. 日志满足脱敏要求:不记录 token 与 raw diff。 + +--- + +## 8. 推荐测试清单 + +| 用例 | 输入场景 | 期望 | +|------|----------|------| +| 主路径(成功->失败窗口) | `repo_hint` 完整 + `last_success_batch` 可用 + 时间窗有提交 | `since=last_success_batch` 且返回非空 `suspect_patches[]` | +| 兜底时间窗 | `last_success_batch` 缺失或非法 | 回退 `since=until-7d` 并有可观察标记 | +| 无仓库映射 | `repo_hint` 空 | 跳过 code blame,`data_gaps` 可解释 | +| 时间窗无提交 | list 返回空 | `suspect_patches=[]` 且状态正常 | +| path 过滤生效 | 提供 `path_hints` | 结果集中仅出现相关路径提交(或显著减少) | +| TopN 限制 | list 返回 >30,TopN=3 | 仅对 3 条拉 diff | +| diff 截断 | 单条 diff 超 `max_lines` | `truncated=true` 且行数受控 | +| 单条 diff 失败 | 某 sha 返回 5xx | 跳过该条,其他条继续 | +| 401 fail-loud | token 无效 | 返回 500,不走 `partial` | + +--- + +## 9. 与相邻分期衔接 + +- **对 A2**:强依赖 `repo_hint` 的正确映射;B5 不负责映射生产。 +- **对 B2**:B5 作为 `code_blame_skill` 的能力增强,不改变三阶段主框架。 +- **对 B4**:B4 提供视觉证据,B5 提供代码变更证据,两者在 Synthesize 互补。 +- **对 C2**:追问阶段默认复用缓存 `suspect_patches`,减少重复调用 CodeHub。 + +--- + +## 10. 维护约定 + +- 若 CodeHub API endpoint/认证格式变化:先更新架构或接入 ADR,再同步本文件与实现。 +- 若未来新增 `gitlab/gitea` provider:更新 `CodeRepoClient` 兼容矩阵与 B5 测试清单。 +- 任何对 fail-loud 条件(尤其 401)的调整,必须同步更新架构与本阶段 DoD。 + +### 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-23 | 初稿:B5 范围、Tool 契约、降级/401 语义、DoD 与测试清单 | +| 2026-04-23 | 调整时间窗策略:优先 `last_success_batch -> failed_batch`,缺失时回退 `-7d` | diff --git a/docs/superpowers/specs/aifa-phase-c1-drawer-tab-lazyload-spec.md b/docs/superpowers/specs/aifa-phase-c1-drawer-tab-lazyload-spec.md new file mode 100644 index 0000000..ee6a42b --- /dev/null +++ b/docs/superpowers/specs/aifa-phase-c1-drawer-tab-lazyload-spec.md @@ -0,0 +1,192 @@ +# AIFA Phase C1 — Drawer Tab 与懒加载前端集成规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **C1** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(§3.4、§9.1、§9.5、ADR-19/20;冲突时以架构为准) + 2. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 **C1** 与依赖关系) + 3. `aifa-phase-a3-sse-report-contract-spec.md`(SSE 事件与最小报告契约) + 4. `aifa-phase-a4-apply-failure-reason-spec.md`(一键入库接口,C1 仅消费不扩展) +- **对应分期**:实现计划 **C1** — **Drawer Tab + 懒加载**:在详细执行历史 Drawer 中新增「AI 归因(beta)」Tab,采用按需挂载与手动触发分析,保证 `HistoryPage` 最小侵入改动。 +- **状态**:Draft +- **日期**:2026-04-23 + +--- + +## 0. 文档目的 + +本文档回答:**C1 合并时前端必须具备哪些 UI 集成边界、懒加载行为、状态最小集、错误体验与验收标准**。 +目标是让 AI 分析能力以低风险方式接入现有 Drawer,不引入无意的 LLM 成本,也不继续膨胀 `HistoryPage`。 + +**C1 不包含**(由其他分期负责): + +| 分期 | 内容 | +|------|------| +| **C2** | 追问交互完整闭环与多轮会话体验增强 | +| **C3** | 一键入库 owner 全规则与现网「分析处理」细则对齐 | +| **C4** | Trace、metrics、token 熔断与成本观测体系 | +| **D1** | 批量勾选、后台队列、进度、重试、取消 | + +--- + +## 1. 职责边界 + +### 1.1 必须满足 + +- **入口位置固定**:仅在详细执行历史 Drawer 内新增「**AI 归因(beta)**」Tab,与现有「失败归因」并列。 +- **懒加载固定语义**: + - 用户未切换到该 Tab 前,不 mount AI 组件; + - 首次 mount 后也**不自动发起分析请求**; + - 必须由用户点击「开始分析」才发请求。 +- **`HistoryPage` 最小改动**:仅保留 Tab 挂载与必要参数透传,不在 `HistoryPage.tsx` 内编写 AI 业务逻辑。 +- **组件独立目录**:AI 相关 UI、状态管理、服务调用必须放在独立目录(见 §2)。 +- **降级可用**:AI 服务不可用时,仅影响该 Tab,Drawer 其他 Tab 与页面主流程不受影响。 + +### 1.2 明确禁止 + +- **禁止**在打开 Drawer、切换其他 Tab、列表刷新等动作里隐式触发 AI 分析请求。 +- **禁止**将 SSE 解析、会话状态机、报告组装逻辑直接写进 `HistoryPage.tsx`。 +- **禁止**以“预取”为名在后台自动分析,导致用户未点击也产生 token 成本。 +- **禁止**将 C2 的追问与会话复用需求提前塞入 C1 交付范围。 + +### 1.3 可选(C1 允许分步) + +- 初版可不展示复杂时间线细节,先保证基础状态:未开始 / 分析中 / 成功 / 失败。 +- 初版可先实现单次分析结果展示,追问入口可占位或显式标注“后续支持”。 + +--- + +## 2. 前端目录与模块划分(C1 最小) + +建议遵循架构约束,在以下位置承载 C1: + +``` +frontend/src/pages/history/components/ai_analysis/ + ├── AIFailureAnalysisTab.tsx Tab 容器(轻协调,不承载复杂逻辑) + ├── AnalysisTrigger.tsx 开始分析按钮与空态 + ├── ProgressStream.tsx 进度态展示(SSE 文案/阶段) + ├── ReportView.tsx 分析结果展示(按契约最小字段) + └── useAIAnalysis.ts 请求、状态机、错误处理封装 + +frontend/src/services/aiAnalysisService.ts API/SSE 调用封装 +``` + +**说明**: + +- 文件命名可按项目既有风格微调,但职责拆分必须保持“页面轻、组件化、hook 收敛逻辑”。 +- `HistoryPage.tsx` 仅负责把 `history_id` 等必要上下文传入 Tab 组件。 + +--- + +## 3. 交互与状态语义(C1 范围) + +### 3.1 用户路径(必须) + +1. 用户进入某条失败记录的 Drawer。 +2. 切换到「AI 归因(beta)」Tab(此时才 mount 组件)。 +3. 页面展示空态与「开始分析」按钮。 +4. 用户点击按钮后发起分析请求并展示进行中状态。 +5. 请求结束后进入成功展示或失败提示态。 + +### 3.2 状态最小集(必须) + +| 状态 | 触发条件 | UI 表现 | +|------|----------|---------| +| `idle` | 初次 mount,尚未点击开始 | 空态说明 + 开始分析按钮 | +| `loading` | 已点击开始,等待/接收 SSE | 进度提示、禁用重复点击 | +| `ready` | 收到最终 report | 渲染结构化结果 | +| `error` | 请求失败或 SSE error | 中文错误提示 + 重试按钮 | + +### 3.3 并发与重复点击(必须) + +- `loading` 期间同一 `history_id` 的「开始分析」按钮需禁用,避免重复发起。 +- 若用户点击「重试」,允许重新进入 `loading`,并覆盖前一次失败态。 + +--- + +## 4. API 与数据契约使用约束 + +### 4.1 C1 对后端接口的依赖 + +- 仅调用已定义的分析接口(A3 契约)与可选的一键入库接口(A4 契约)。 +- C1 不新增后端字段,不修改既有 SSE 事件类型语义。 + +### 4.2 请求触发条件(必须) + +- **唯一触发点**:用户点击「开始分析」。 +- 请求入参至少包含当前记录标识(如 `history_id`);其余参数按 A3 规范由现有实现决定。 + +### 4.3 结果渲染边界(C1) + +- 优先渲染 A3 已稳定字段(如分类、摘要、结论、证据摘要中的可用子集)。 +- 对缺失字段采用“可见但不报错”的降级渲染,不因单字段缺失导致整卡白屏。 + +--- + +## 5. 性能与体验要求 + +- **懒加载收益**:未进入 AI Tab 的用户不产生组件渲染与网络请求开销。 +- **首屏稳定**:Drawer 默认 Tab 打开与滚动性能不应因 C1 明显退化。 +- **请求时机可解释**:用户可明确感知“点击开始分析”才会触发 AI 调用。 +- **失败可恢复**:失败态必须提供重试入口,且不影响用户切回其他 Tab 查看现有信息。 + +--- + +## 6. 错误与降级语义 + +| 场景 | 期望行为 | +|------|----------| +| AI 服务不可用/超时 | 仅该 Tab 显示中文错误提示,不影响 Drawer 其他内容 | +| SSE 中断但已有部分进度 | 可显示“分析中断,请重试”并回到 `error` | +| 返回 report 缺少非关键字段 | 以占位文案降级展示,仍进入 `ready` | +| 用户未点击开始分析 | 保持 `idle`,不出现任何隐式请求副作用 | + +--- + +## 7. 验收标准(C1 DoD) + +以下全部满足,视为 **C1 完成**: + +1. Drawer 中新增「AI 归因(beta)」Tab,且位于现有失败归因相关区域内。 +2. AI 组件按 Tab 懒加载:未切换到 Tab 前不 mount、不请求。 +3. 首次进入 Tab 不自动分析,必须点击「开始分析」才请求。 +4. `HistoryPage.tsx` 仅做最小接入改动,AI 逻辑位于独立目录组件/hook/service。 +5. `idle/loading/ready/error` 四态完整可见,加载中可防重复提交。 +6. AI 接口失败时为局部失败:仅该 Tab 提示错误,页面其余功能正常。 +7. 交互文案为中文,按钮与提示语可被测试用例稳定断言。 + +--- + +## 8. 推荐测试清单 + +| 用例 | 操作 | 期望 | +|------|------|------| +| 懒加载生效 | 打开 Drawer 但不切换 AI Tab | AI 组件未挂载、无分析请求 | +| 首次进入不自动请求 | 切换到 AI Tab | 仍为 `idle`,仅见开始按钮 | +| 手动触发分析 | 点击开始分析 | 进入 `loading` 并发起一次请求 | +| 防重复提交 | `loading` 期间重复点击 | 不产生第二次请求 | +| 成功态渲染 | mock 返回完整 report | 进入 `ready`,关键字段可见 | +| 失败态恢复 | mock 接口失败后点重试 | 从 `error` 回到 `loading`,可再次请求 | +| 局部降级 | AI 请求失败后切回其他 Tab | 其他 Tab 信息与交互正常 | + +--- + +## 9. 与相邻分期衔接 + +- **对 A3**:复用既有 SSE/报告契约;C1 不扩充事件协议。 +- **对 A4/C3**:若页面存在“一键设置到失败原因”按钮,C1 仅接线,具体映射规则以后续分期为准。 +- **对 C2**:C1 保留可扩展状态与容器结构,便于后续挂接追问与 session 复用。 +- **对 C4**:C1 可预留埋点位,但不要求在本期完成全量观测指标。 + +--- + +## 10. 维护约定 + +- 若架构 §9 对组件边界或懒加载语义有调整:先更新架构,再同步本文件。 +- 若 `HistoryPage` 结构重构导致接入点变化:保持“最小侵入 + AI 逻辑外置”原则不变。 +- C1 进入实现后,需在本文件补充“已落地文件清单”与“偏差说明(如有)”。 + +### 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-23 | 初稿:C1 范围、懒加载语义、目录边界、DoD 与测试清单 | diff --git a/docs/superpowers/specs/aifa-phase-c4-observability-cost-spec.md b/docs/superpowers/specs/aifa-phase-c4-observability-cost-spec.md new file mode 100644 index 0000000..ed2461e --- /dev/null +++ b/docs/superpowers/specs/aifa-phase-c4-observability-cost-spec.md @@ -0,0 +1,220 @@ +# AIFA Phase C4 — 观测与成本(trace / metrics / token 熔断)规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **C4** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(§10 错误分级、§11 健康检查与观测、§12 安全与成本边界;冲突时以架构为准) + 2. `2026-04-08-ai-failure-analysis-tech-selection.md`(JSONL trace、`/metrics` JSON、熔断与超时建议) + 3. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 **C4** 与依赖关系) + 4. `aifa-phase-a3-sse-report-contract-spec.md`(`status=ok|partial|error` 与 SSE 终态语义) + 5. `aifa-phase-a5-rate-limit-spec.md`(dt-report 侧限流边界,C4 仅观测不改 A5 规则) +- **对应分期**:实现计划 **C4** — **观测与成本**:建立可追踪链路、可聚合指标、单请求 token 成本护栏(熔断),并固化错误分级与降级可解释性。 +- **状态**:Draft +- **日期**:2026-04-23 + +--- + +## 0. 文档目的 + +本文档回答:**C4 合并时系统必须具备哪些观测字段、指标口径、成本计算与熔断行为,以及怎样验收**。 +目标是让 AI 分析从“能跑”升级到“可运维、可控成本、可审计”。 + +**C4 不包含**(由其他分期负责): + +| 分期 | 内容 | +|------|------| +| **A5** | 限流策略本身(单 `history_id` 10 次/分钟) | +| **C1/C2/C3** | 前端 Tab、追问体验、入库 owner 规则 | +| **D1** | 批量队列任务状态机与队列持久化 | +| **平台化告警系统接入** | Prometheus/ELK/ClickHouse 正式接入(C4 先提供可接入输出) | + +--- + +## 1. 职责边界 + +### 1.1 必须满足 + +- **trace 完整**:每次分析请求必须有一条可检索 trace,至少覆盖请求上下文、阶段耗时、skill/tool 调用摘要、token 消耗、最终状态。 +- **metrics 可读**:提供统一指标端点(初期 JSON),支持请求量、成功率、partial/error 比例、耗时、token 与成本聚合。 +- **token 熔断生效**:单请求 token 累计达到上限时,必须触发熔断,终态为 `partial`(配置类致命错误除外)。 +- **错误分级统一**:`ok/partial/error` 与 SSE/前端语义一致,日志、trace、指标口径一致。 +- **脱敏合规**:日志与 trace 禁止泄露 token、完整敏感 URL query、原始大段证据正文。 + +### 1.2 明确禁止 + +- **禁止**只打“文本日志”而无结构化 trace。 +- **禁止**在 token 超限后继续深度调用 LLM(导致成本失控)。 +- **禁止**把暂时性外部依赖故障全部升级成 HTTP 500(应优先 partial)。 +- **禁止**让 metrics 口径与 trace 字段定义不一致。 + +### 1.3 可选(C4 允许分步) + +- 初版可仅输出 `/metrics` JSON,不强制 Prometheus 文本格式。 +- 初版成本可按“token * 单价”估算,不要求精确对账到供应商账单。 + +--- + +## 2. Trace 规格(必须) + +## 2.1 记录范围 + +每次 `/ai/analyze` 请求生成一条 trace 记录(建议 JSONL 一行一条),覆盖: + +- 请求标识:`request_id`、`session_id`、`history_id` +- 时序:`started_at`、`finished_at`、`elapsed_ms` +- 结果:`status`(`ok|partial|error`)、`error_code?`、`error_message?` +- 调用摘要:`skills_invoked[]`、`tool_calls[]`(仅摘要) +- token:`llm_input_tokens`、`llm_output_tokens`、`total_tokens` +- 成本:`estimated_cost`(含计价参数版本) +- 降级:`data_gaps[]`、`degrade_reasons[]` + +## 2.2 最小结构(示意) + +```json +{ + "request_id": "uuid", + "session_id": "uuid", + "history_id": 123456, + "status": "ok", + "elapsed_ms": 18740, + "skills_invoked": ["report_analysis", "screenshot", "synthesis"], + "tool_calls": [{"name": "fetch_report_html", "ok": true, "elapsed_ms": 230}], + "llm_input_tokens": 12034, + "llm_output_tokens": 1820, + "total_tokens": 13854, + "estimated_cost": 0.31, + "data_gaps": [], + "degrade_reasons": [] +} +``` + +## 2.3 脱敏规则(必须) + +- 禁止落:API key、service token、完整 Authorization Header。 +- 禁止落:完整 raw diff、完整 HTML、完整 base64 图片。 +- URL 仅保留 `host + path` 或经过脱敏处理的片段。 + +--- + +## 3. Metrics 规格(必须) + +## 3.1 指标端点 + +- 端点:`GET /metrics` +- 初期返回 JSON(后续可加 `/metrics/prom` 适配层) +- 指标按进程内聚合,重启清零可接受(需在文档注明) + +## 3.2 最小指标集 + +| 指标名 | 类型 | 说明 | +|------|------|------| +| `requests_total` | counter | 总请求数 | +| `requests_ok` | counter | 成功请求数 | +| `requests_partial` | counter | 降级完成请求数 | +| `requests_error` | counter | 失败请求数 | +| `request_latency_p50_ms` | gauge | 请求耗时 P50 | +| `request_latency_p95_ms` | gauge | 请求耗时 P95 | +| `tokens_input_total` | counter | 输入 token 总量 | +| `tokens_output_total` | counter | 输出 token 总量 | +| `tokens_total` | counter | 总 token | +| `estimated_cost_total` | counter | 估算总成本 | +| `circuit_breaker_triggered_total` | counter | token 熔断触发次数 | +| `external_dependency_error_total` | counter | 外部依赖错误次数(可按来源分桶) | + +## 3.3 口径一致性(必须) + +- `requests_ok + requests_partial + requests_error == requests_total` +- token 与成本统计口径必须与 trace 字段一致 +- 熔断触发必须同时体现在: + - trace:`degrade_reasons` 包含 token 超限 + - metrics:`circuit_breaker_triggered_total` 增加 + +--- + +## 4. Token 成本与熔断规则(必须) + +## 4.1 配置项 + +| 配置 | 说明 | +|------|------| +| `AIFA_MAX_TOKENS_PER_REQUEST` | 单请求 token 硬上限 | +| `AIFA_PRICE_PER_1K_INPUT` | 输入 token 单价(估算) | +| `AIFA_PRICE_PER_1K_OUTPUT` | 输出 token 单价(估算) | +| `AIFA_MAX_CONCURRENT_ANALYSES` | 并发上限(与 C4 指标联动观测) | + +## 4.2 熔断触发条件 + +- 当 `累计输入+输出 token >= AIFA_MAX_TOKENS_PER_REQUEST` 时触发 +- 触发后行为: + 1. 停止后续高成本 LLM 调用; + 2. 保留已获得证据并合成最小可解释结果; + 3. 返回 `status=partial`; + 4. `data_gaps` 写明“因 token 上限触发熔断”。 + +## 4.3 成本计算(估算) + +- `estimated_cost = input_tokens / 1000 * input_price + output_tokens / 1000 * output_price` +- 每请求写入 trace;metrics 聚合写入 `estimated_cost_total` + +--- + +## 5. 错误分级与降级语义(C4 固化) + +| 场景 | 等级 | 期望行为 | +|------|------|----------| +| 外部数据源超时/5xx(非配置) | partial | 返回可用报告 + `data_gaps` | +| 单 skill/tool 失败但主流程可继续 | partial | 不中断整单 | +| token 触顶熔断 | partial | 立即停止扩展调用并返回可解释降级 | +| 配置错误(如 token 无效/缺失) | error/fatal | fail-loud,返回 500 | +| 参数不合法 | user error | 返回 400,中文可读 | + +**核心原则**:有可用结论就优先 `partial`,无可用结果再 `error`。 + +--- + +## 6. 验收标准(C4 DoD) + +以下全部满足,视为 **C4 完成**: + +1. 每次分析请求都能产出结构化 trace,包含最小必填字段。 +2. `/metrics` 可稳定返回,且关键计数关系成立(见 §3.3)。 +3. 单请求 token 超限会触发熔断并返回 `partial`,不会继续高成本调用。 +4. trace、metrics、前端终态的 `ok/partial/error` 语义一致。 +5. 错误分级可复现:外部依赖故障走 partial,配置类故障 fail-loud。 +6. 日志与 trace 通过脱敏检查,不含明文 token 与大段敏感正文。 +7. 有每日成本汇总输出(日志或报告),至少含请求数、总成本、p95。 + +--- + +## 7. 推荐测试清单 + +| 用例 | 输入场景 | 期望 | +|------|----------|------| +| 正常请求 | 依赖全部可用 | `status=ok`,trace+metrics 计数增长 | +| 单依赖超时 | 报告或截图源超时 | `status=partial`,`data_gaps` 可解释 | +| token 熔断 | 人工降低 `AIFA_MAX_TOKENS_PER_REQUEST` | 触发 partial,熔断计数+1 | +| 配置错误 | CodeHub token 无效 | fail-loud(500),error 计数增长 | +| 指标一致性 | 连续发送 N 次混合请求 | 三类状态计数求和等于总请求 | +| 脱敏检查 | 注入敏感 URL/token 场景 | trace/log 不出现明文敏感信息 | +| 成本聚合 | 多请求后读取 metrics | `estimated_cost_total` 与 trace 聚合同量级 | + +--- + +## 8. 与相邻分期衔接 + +- **对 A5**:C4 观测 A5 限流命中,不改 A5 阈值语义。 +- **对 C1/C2/C3**:前端展示可消费 `status` 与 trace 摘要,但 C4 不定义 UI 细节。 +- **对 D1**:批量队列上线后复用同一 trace/metrics 口径,按 `task_id` 扩展维度。 + +--- + +## 9. 维护约定 + +- 新增/调整任何关键指标字段时,必须同步更新本文件与运维说明。 +- 若错误分级规则调整(特别是 partial 与 fail-loud 边界),需同步更新架构与本文件。 +- 未来接 Prometheus/ELK 时,应通过适配层扩展,尽量保持既有字段语义不破坏。 + +### 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-23 | 初稿:C4 范围、trace 结构、metrics 口径、token 熔断、DoD 与测试清单 | diff --git a/docs/superpowers/specs/dt-report-phase-a2-ai-context-builder-spec.md b/docs/superpowers/specs/dt-report-phase-a2-ai-context-builder-spec.md new file mode 100644 index 0000000..c872fba --- /dev/null +++ b/docs/superpowers/specs/dt-report-phase-a2-ai-context-builder-spec.md @@ -0,0 +1,166 @@ +# dt-report Phase A2 — `ai_context_builder` 阶段规格说明 + +- **文档类型**:阶段规格(Phase Spec,仅描述 **A2** 交付范围) +- **关联文档**(单一事实来源优先级): + 1. `2026-04-08-ai-failure-analysis-architecture.md`(§1.4、§3.3、§4.1 请求契约;冲突时以架构为准) + 2. `2026-04-14-ai-failure-analysis-implementation-plan.md`(分期 **A2** 与依赖关系) + 3. `aifa-phase-a1-service-spec.md`(AIFA 对请求 JSON 的校验形态;A2 产出须可被其消费) +- **对应分期**:实现计划 **A2** — **真实 payload**:在 **dt-report** 侧实现 **`ai_context_builder`**,根据 **`history_id`** 只读拼装发往 AIFA 的 **`POST /v1/analyze` 请求体**(与架构 **§4.1** 对齐)。 +- **状态**:Draft +- **日期**:2026-04-18 + +--- + +## 0. 文档目的 + +本文档回答:**A2 合并时必须具备哪些行为、数据范围、配置与验收标准**;不重复架构全文,只固化 **A2 范围内的「必须 / 可选 / 禁止」**。 + +**A2 不包含**(由其他分期负责): + +| 分期 | 内容 | +|------|------| +| **A3** | AIFA 侧 SSE `progress` 丰富度、`report` 字段与架构 §4.3 的终态对齐 | +| **A4** | 用户「接受 / 拒绝」、写入 `pipeline_failure_reason`、`analyzed` 等 | +| **A5** | 按 `history_id` 限流(架构 §12.4) | +| **Phase B** | 报告/截图 Tool、多阶段 Agent、截图索引解析、CodeHub 等 | +| **Phase C1** | 前端 Drawer、AI 归因 Tab、懒加载(见架构 §9) | + +**说明**:架构 §3.3 将 **`ai_proxy.py`**(JWT、转发 AIFA、SSE、审计等)与 **`ai_context_builder`** 并列列出;**实现计划将「限流 / 接受拒绝」记在 A4/A5**。本文档 **A2 仅覆盖 `ai_context_builder` 与由其组装的 payload**。与 **「最小 `POST /api/v1/ai/analyze` 代理」** 是否同批合入,由排期决定:代理 **不属于** A2 编号条目,但 **端到端联调** 常与之同批。 + +--- + +## 1. 职责边界 + +### 1.1 必须满足 + +- **输入**:至少支持按 **`history_id`**(`pipeline_history.id`)定位当前失败行。 +- **输出**:一份 **JSON 对象**,语义对齐架构 **§4.1**,可被当前 **`ai-failure-analyzer`** 的 `POST /v1/analyze` 请求体验证逻辑接受(字段 **大部分可选**,未实现的键可 **缺省** 或 **`null`**,**不得**要求 AIFA 访问 MySQL 补数据,与架构 §4.1 末段一致)。 +- **数据来源**:**仅 dt-report 已具备的数据库只读访问与配置**(如 `module_repo_mapping`);**不做** LLM 调用、**不做** Prompt 拼装(架构 §3.3:纯数据读取)。 +- **历史执行**:提供 **`recent_executions`**:与架构一致,按 **`(case_name, platform)`** 查询近 **N** 条执行摘要(默认 **N=20**,可配置;实现阶段固定常量即可)。 +- **仓库提示**:提供 **`repo_hint`**:来自 **dt-report 维护** 的 **`main_module` → 仓库** 映射(**初期为 YAML 配置文件**,与架构 §4.1 一致;后续若升级为字典表,不在 A2 强制要求)。 + +### 1.2 明确禁止 + +- **禁止**在发往 AIFA 的 payload 中包含 **日志 HTML URL**(架构 **§1.4.1**、**§4.1**)。 + - 说明:MySQL `pipeline_history.log_url` 仅可在 **dt-report 内部**用于其他用途;**写入 AIFA 请求 JSON 的字段集合中不得出现** 与「整页日志 HTML」等价的对外传递(改版后 Phase B 以 `reports_url` 与 `screenshot_url` 为主要证据来源)。 +- **禁止**在 `ai_context_builder` 内调用 AIFA 或任何 LLM。 +- **禁止**违反项目数据库红线:对既有表的 **ALTER / DROP**、对 `pipeline_history` / `pipeline_overview` 的 **DELETE**、**ORM 自动建表** 等(见仓库 `.cursor/rules/project.mdc`)。 + +### 1.3 可选(A2 允许分步) + +实现计划 **A2** 说明:**截图可先直链或空**。下列字段在架构 §4.1 与 §1.4.1 中有定义,**A2 第一期允许**: + +- **`screenshot_index_url` / `screenshot_urls[]`**:若库中已有 **截图 URL**(如 `pipeline_history.screenshot_url` 或后续字段),可 **原样或规范化** 填入;若无或不可靠,**可省略或为空**。 +- **`reports_url`**:若存在 `pipeline_history.reports_url`,可填入;否则可空。 +- **`last_success_batch`、`success_screenshot_index_url`、`success_screenshot_urls[]`**:架构要求由 dt-report 计算 **最近一次成功批次** 并 **仅替换 batch 段** 生成成功侧 URL(§1.4.1)。**A2 允许**在首版 **暂不实现** 或 **部分实现**,但须在 **`data_gaps` 或文档验收**中可说明;**不得**伪造不存在的成功侧证据。 + +--- + +## 2. 与 `pipeline_history` 的字段映射(语义) + +表结构以 **`database/*.sql` 与 `backend/models/pipeline_history.py`** 为准。架构 §4.1 使用 **`batch`** 等命名;当前表以 **`start_time`** 表示轮次(注释:**等同于 batch**)。**A2 在 `case_context` 中应同时满足产品语义**: + +- 将 **`start_time`** 映射为契约中的 **`batch`**(或与架构示例一致的字段名),并在实现与测试中写清对应关系,避免 AIFA 分析维度不一致。 + +其他常见映射(示例,以实现阶段 ORM 字段为准): + +| 架构 `case_context` 语义 | 数据来源(示例) | +|--------------------------|------------------| +| `history_id` | 请求入参或 `pipeline_history.id` | +| `case_name`, `platform`, `case_result`, `code_branch` | 同行字段 | +| `main_module`, `module`, `subtask` | `main_module`, `module`, `subtask` | +| `pipeline_url`, `case_level` | `pipeline_url`, `case_level` | +| 截图 / 报告 URL | `screenshot_url`, `reports_url` 等(**不传 `log_url` 至 AIFA**) | + +--- + +## 3. `recent_executions` 条目形状(建议) + +每条至少包含架构 §4.1 示例中出现的维度,便于 AIFA 单轮或后续 **history_skill** 使用: + +- `start_time`(轮次标识) +- `case_result` +- `code_branch`(若有) + +**查询策略**:遵循项目 **Service 层约定**(默认 **禁止无必要的 JOIN**;优先单表条件查询 + 分页/限制条数),复用或抽取 **`history_service`** 中与「同 case、同 platform」相关的查询逻辑(架构 §3.3)。 + +--- + +## 4. `repo_hint`(YAML 配置) + +### 4.1 最小结构(与架构 §4.1 示例对齐) + +```yaml +# 示例结构;键名与映射规则以实现为准,须可映射到 §4.1 的 repo_hint +mappings: + - main_module: "auth" + repo_url: "https://codehub.internal/group/project" + default_branch: "master" + path_hints: + - "src/auth/" + - "tests/auth/" +``` + +### 4.2 行为 + +- 按当前失败行的 **`main_module`** 查找配置;**未命中**时:`repo_hint` 可为 **空对象** 或 **字段为 null**,**不得**因缺映射而抛未捕获异常导致分析入口 500(与「降级优先」精神一致;具体 HTTP 行为由 **调用方 / A4** 定义)。 + +### 4.3 实现约定(与代码同步) + +- **Service**:`backend/services/ai_context_builder.py`,入口 **`build_analyze_payload(db, history_id)`**,返回 **`case_context` + `recent_executions` + `repo_hint`**(**不含** `session_id` / `mode`,由后续 `ai_proxy` 合并)。 +- **环境变量**:**`AI_MODULE_REPO_MAPPING_PATH`** — 指向 UTF-8 YAML 文件的绝对或相对路径;**空** 表示不加载,`repo_hint` 为空对象。模板见仓库根目录 **`config/module_repo_mapping.yaml.example`**。 +- **历史执行查询**:`backend/services/history_service.py` 中的 **`list_recent_executions_by_case_platform`**(单表、同 `case_name`+`platform`、默认最多 20 条)。 + +--- + +## 5. 体积与安全 + +- 对 **`case_context` 内字符串字段**、**`recent_executions` 列表长度与单条字段** 实施 **上限与截断**(与架构「Token 成本」及 A1「安全截断」方向一致;具体字节数可在实现中常量配置)。 +- **不在日志中打印**完整 URL 或密钥;若需调试,使用 **脱敏** 或 **DEBUG 且受控**(见 `docs/06_logging_guide.md`)。 + +--- + +## 6. 错误与降级 + +| 场景 | 期望行为 | +|------|----------| +| `history_id` 不存在 | 由 **调用方**(如未来的 `ai_proxy`)返回 **404** 或业务错误码;builder 可抛 **明确异常** 或返回 **Result 类型**,由 API 层统一转换为中文错误信息 | +| `case_name` / `platform` 为空导致无法查历史 | `recent_executions` 可为 **空数组**;不阻塞 payload 生成(若业务要求必须拒绝,由评审决定) | +| 配置缺失 | `repo_hint` 降级为空;**不打 ERROR**(除非文件损坏且无法解析) | + +--- + +## 7. 分层与代码位置(建议) + +与仓库 **Model → Schema → Service → API** 契约一致(见 `.cursor/rules/project.mdc`): + +- **Service**:`backend/services/ai_context_builder.py`(或等价路径),**纯 async 函数**,例如 `async def build_analyze_payload(db: AsyncSession, history_id: int) -> dict`(返回体形状与架构 §4.1 一致;具体是否使用 Pydantic **内部** 模型由实现定)。 +- **API**:**不属于 A2 必交付**;若仅有单元测试调用 Service,仍视为 A2 可合并。 + +--- + +## 8. 自动化测试(DoD) + +以下满足可视为 **A2 完成**: + +1. **单测或集成测试**:给定 **fixture / 测试库** 中的一条 `pipeline_history`,`build_analyze_payload` 产出的 dict **可被序列化为 JSON**,且: + - **不包含** 键名或语义等价于「日志页 HTML URL」的对外字段(即不与架构 **禁止传日志 URL** 冲突); + - 包含 **`case_context`**,且其中 **`case_name`、`batch`(或映射自 `start_time`)、`platform`** 与源行一致; + - **`recent_executions`** 为列表,条数 **≤ N**; + - **`repo_hint`** 在配置存在时非空、配置不存在时可空。 +2. **回归**:不破坏现有 **pipeline_history** 只读路径;**无** 违规 DDL/DML。 + +--- + +## 9. 与相邻切片的衔接 + +- **A1**:AIFA 已能校验并处理 §4.1 **子集**;A2 保证 dt-report **真实填数**。 +- **(可选同批)`ai_proxy`**:接收前端 **`history_id`**,调用 `build_analyze_payload`,再 **httpx 转发** AIFA SSE;JWT、审计、超时见架构 §3.3、§12。 +- **A5**:在 **代理层** 对 **`history_id`** 限流,**不在** `ai_context_builder` 内实现。 + +--- + +## 10. 维护约定 + +- 若架构 **§4.1** 字段变更,**先改架构 SSOT**,再同步本文档与实现。 +- 本文档仅描述 **A2**;**五 Skill / Tool** 见架构 §6 与 **Phase B** 实现计划。 diff --git a/frontend/src/pages/history/HistoryPage.tsx b/frontend/src/pages/history/HistoryPage.tsx index cca6438..8337b0b 100644 --- a/frontend/src/pages/history/HistoryPage.tsx +++ b/frontend/src/pages/history/HistoryPage.tsx @@ -18,6 +18,7 @@ import { Select, Spin, Table, + Tabs, Tag, Tooltip, Typography, @@ -38,6 +39,7 @@ import { type InheritSourceRecordItem, type BatchReportResponse, } from "../../services"; +import AIFailureAnalysisTab from "./components/ai_analysis/AIFailureAnalysisTab"; const { Text, Title, Paragraph } = Typography; const REASON_CACHE_KEY = "history_failure_reason_cache"; @@ -241,6 +243,7 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { const [optionsLoading, setOptionsLoading] = useState(false); const [drawerVisible, setDrawerVisible] = useState(false); const [drawerRecord, setDrawerRecord] = useState(null); + const [drawerFailureTabKey, setDrawerFailureTabKey] = useState("failure"); const [reasonExpanded, setReasonExpanded] = useState(false); const [form] = Form.useForm(); const [processForm] = Form.useForm(); // 标注弹窗表单 @@ -1091,6 +1094,7 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { const handleRowClick = (record: HistoryItem) => { setDrawerRecord(record); setDrawerVisible(true); + setDrawerFailureTabKey("failure"); setReasonExpanded(false); }; @@ -1986,8 +1990,12 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { title={执行详情} placement="right" width={480} - onClose={() => setDrawerVisible(false)} + onClose={() => { + setDrawerVisible(false); + setDrawerFailureTabKey("failure"); + }} open={drawerVisible} + destroyOnClose > {drawerRecord && ( <> @@ -2038,72 +2046,89 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) { {(drawerRecord.case_result === "failed" || drawerRecord.case_result === "error") && ( <> -
- - 失败归因 - -
-
-
- 跟踪人: - {drawerRecord.failure_owner ?? "—"} -
-
- 失败原因: - {drawerRecord.failed_type ?? "—"} -
- {/* 粗略按字符数判断是否“超过约 3 行”,控制是否展示展开/收起按钮 */} - {(() => { - const text = drawerRecord.reason ?? ""; - const canExpand = text.length > 100; // 约等于 3 行以上的长文案 - return ( -
- 详细原因: - {text ? ( -
- {text} -
- ) : ( - "—" - )} - {text && canExpand && ( - - )} -
- ); - })()} -
- 分析人: - {drawerRecord.failure_analyzer ?? "—"} -
-
- 分析时间: - {drawerRecord.analyzed_at - ? drawerRecord.analyzed_at.replace("T", " ") - : "—"} -
-
+ +
+ 跟踪人: + {drawerRecord.failure_owner ?? "—"} +
+
+ 失败原因: + {drawerRecord.failed_type ?? "—"} +
+ {(() => { + const text = drawerRecord.reason ?? ""; + const canExpand = text.length > 100; + return ( +
+ 详细原因: + {text ? ( +
+ {text} +
+ ) : ( + "—" + )} + {text && canExpand && ( + + )} +
+ ); + })()} +
+ 分析人: + {drawerRecord.failure_analyzer ?? "—"} +
+
+ 分析时间: + {drawerRecord.analyzed_at + ? drawerRecord.analyzed_at.replace("T", " ") + : "—"} +
+ + ), + }, + { + key: "ai", + label: "AI 归因(beta)", + children: ( + + ), + }, + ]} + /> )} diff --git a/frontend/src/pages/history/components/ai_analysis/AIFailureAnalysisTab.tsx b/frontend/src/pages/history/components/ai_analysis/AIFailureAnalysisTab.tsx new file mode 100644 index 0000000..54a9599 --- /dev/null +++ b/frontend/src/pages/history/components/ai_analysis/AIFailureAnalysisTab.tsx @@ -0,0 +1,84 @@ +import { Alert, Button, Space, Typography } from "antd"; +import AnalysisTrigger from "./AnalysisTrigger"; +import ProgressStream from "./ProgressStream"; +import ReportView from "./ReportView"; +import { useAIAnalysis } from "./useAIAnalysis"; + +const { Text } = Typography; + +interface AIFailureAnalysisTabProps { + historyId: number; + caseResult?: string | null; +} + +export default function AIFailureAnalysisTab({ historyId, caseResult }: AIFailureAnalysisTabProps) { + const { + status, + sessionId, + progressEvents, + report, + errorMessage, + applying, + rejecting, + startInitialAnalyze, + retryAnalyze, + applyFailureReason, + rejectDraft, + } = useAIAnalysis(historyId); + + if (caseResult !== "failed" && caseResult !== "error") { + return ; + } + + if (status === "idle") { + return void startInitialAnalyze()} />; + } + + if (status === "loading") { + return ( +
+
+ 会话 ID:{sessionId} +
+ +
+ ); + } + + if (status === "error") { + return ( + + + + + ); + } + + if (!report) { + return ( + + ); + } + + return ( + void applyFailureReason()} + onReject={() => void rejectDraft()} + /> + ); +} diff --git a/frontend/src/pages/history/components/ai_analysis/AnalysisTrigger.tsx b/frontend/src/pages/history/components/ai_analysis/AnalysisTrigger.tsx new file mode 100644 index 0000000..c8a99dc --- /dev/null +++ b/frontend/src/pages/history/components/ai_analysis/AnalysisTrigger.tsx @@ -0,0 +1,26 @@ +import { Button, Space, Typography } from "antd"; + +const { Paragraph, Text } = Typography; + +interface AnalysisTriggerProps { + disabled?: boolean; + loading?: boolean; + onStart: () => void; +} + +export default function AnalysisTrigger({ disabled, loading, onStart }: AnalysisTriggerProps) { + return ( +
+ + + 点击开始后才会发起 AI 分析请求;未点击前不会产生额外分析成本。 + + + + + +
+ ); +} diff --git a/frontend/src/pages/history/components/ai_analysis/ProgressStream.tsx b/frontend/src/pages/history/components/ai_analysis/ProgressStream.tsx new file mode 100644 index 0000000..a8e1159 --- /dev/null +++ b/frontend/src/pages/history/components/ai_analysis/ProgressStream.tsx @@ -0,0 +1,38 @@ +import { Alert, List, Tag, Typography } from "antd"; +import type { ProgressEventPayload } from "../../../../services/aiAnalysisService"; + +const { Text } = Typography; + +interface ProgressStreamProps { + events: ProgressEventPayload[]; +} + +export default function ProgressStream({ events }: ProgressStreamProps) { + return ( +
+ + ( + +
+
+ {item.stage || "unknown"} +
+ {item.message || "分析中..."} +
+
+ )} + /> +
+ ); +} diff --git a/frontend/src/pages/history/components/ai_analysis/ReportView.tsx b/frontend/src/pages/history/components/ai_analysis/ReportView.tsx new file mode 100644 index 0000000..889167c --- /dev/null +++ b/frontend/src/pages/history/components/ai_analysis/ReportView.tsx @@ -0,0 +1,88 @@ +import { Alert, Button, Card, Descriptions, Divider, List, Space, Tag, Typography } from "antd"; +import type { AiAnalysisReportEnvelope } from "../../../../services/aiAnalysisService"; + +const { Paragraph, Text } = Typography; + +interface ReportViewProps { + reportEnvelope: AiAnalysisReportEnvelope; + applying: boolean; + rejecting: boolean; + onApply: () => void; + onReject: () => void; +} + +export default function ReportView({ + reportEnvelope, + applying, + rejecting, + onApply, + onReject, +}: ReportViewProps) { + const report = reportEnvelope.report; + const confidence = + typeof report?.confidence === "number" ? `${Math.round(report.confidence * 100)}%` : "—"; + const status = reportEnvelope.status || "unknown"; + + return ( +
+ {status}} + > + + {report?.failure_category || "—"} + {report?.summary || "—"} + {confidence} + + + + 详细原因 + + {report?.detailed_reason || "—"} + + + {report?.data_gaps?.length ? ( + <> + + {item}} + /> + } + /> + + ) : null} + + {report?.suggested_next_steps?.length ? ( + <> + + 建议下一步 + {item}} + /> + + ) : null} + + + + + + + +
+ ); +} diff --git a/frontend/src/pages/history/components/ai_analysis/useAIAnalysis.ts b/frontend/src/pages/history/components/ai_analysis/useAIAnalysis.ts new file mode 100644 index 0000000..a408212 --- /dev/null +++ b/frontend/src/pages/history/components/ai_analysis/useAIAnalysis.ts @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { message } from "antd"; +import { + aiAnalysisApi, + type AiAnalysisReportEnvelope, + type ProgressEventPayload, + streamAnalyze, +} from "../../../../services/aiAnalysisService"; + +export type AIAnalysisStatus = "idle" | "loading" | "ready" | "error"; + +export interface UseAIAnalysisResult { + status: AIAnalysisStatus; + sessionId: string; + progressEvents: ProgressEventPayload[]; + report: AiAnalysisReportEnvelope | null; + errorMessage: string; + applying: boolean; + rejecting: boolean; + startInitialAnalyze: () => Promise; + retryAnalyze: () => Promise; + applyFailureReason: () => Promise; + rejectDraft: () => Promise; + resetState: () => void; +} + +function newSessionId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export function useAIAnalysis(historyId: number): UseAIAnalysisResult { + const [status, setStatus] = useState("idle"); + const [sessionId, setSessionId] = useState(() => newSessionId()); + const [progressEvents, setProgressEvents] = useState([]); + const [report, setReport] = useState(null); + const [errorMessage, setErrorMessage] = useState(""); + const [applying, setApplying] = useState(false); + const [rejecting, setRejecting] = useState(false); + const abortRef = useRef(null); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + abortRef.current = null; + }; + }, []); + + useEffect(() => { + // 切换到另一条 history 记录时,清空上一条的分析会话与结果。 + abortRef.current?.abort(); + abortRef.current = null; + setStatus("idle"); + setProgressEvents([]); + setReport(null); + setErrorMessage(""); + setApplying(false); + setRejecting(false); + setSessionId(newSessionId()); + }, [historyId]); + + const startAnalyzeWithSession = useCallback( + async (targetSessionId: string) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setStatus("loading"); + setErrorMessage(""); + setReport(null); + setProgressEvents([]); + + try { + await streamAnalyze( + { + history_id: historyId, + mode: "initial", + session_id: targetSessionId, + }, + { + onProgress: (payload) => { + const next = { + stage: payload.stage || "unknown", + message: payload.message || "分析中...", + elapsed_ms: payload.elapsed_ms, + percent: payload.percent, + detail: payload.detail, + }; + setProgressEvents((prev) => [...prev, next].slice(-30)); + }, + onReport: (payload) => { + setReport(payload); + setStatus("ready"); + }, + onError: (msg) => { + setErrorMessage(msg); + setStatus("error"); + }, + }, + controller.signal, + ); + } catch (err) { + if (controller.signal.aborted) return; + const msg = err instanceof Error ? err.message : "AI 分析失败,请稍后重试"; + setErrorMessage(msg); + setStatus("error"); + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + } + } + }, + [historyId], + ); + + const startInitialAnalyze = useCallback(async () => { + await startAnalyzeWithSession(sessionId); + }, [sessionId, startAnalyzeWithSession]); + + const retryAnalyze = useCallback(async () => { + const nextSession = newSessionId(); + setSessionId(nextSession); + await startAnalyzeWithSession(nextSession); + }, [startAnalyzeWithSession]); + + const applyFailureReason = useCallback(async () => { + const failureCategory = report?.report?.failure_category?.trim(); + const detailedReason = report?.report?.detailed_reason?.trim(); + if (!failureCategory || !detailedReason) { + message.warning("报告缺少可入库字段,暂无法一键设置到失败原因"); + return; + } + setApplying(true); + try { + const res = await aiAnalysisApi.applyFailureReason({ + history_id: historyId, + failure_category: failureCategory, + detailed_reason: detailedReason, + session_id: sessionId, + analysis_draft_id: report?.analysis_draft_id, + }); + message.success(res.message || "已写入失败原因"); + } catch (err) { + const e = err as { response?: { data?: { detail?: string } }; message?: string }; + message.error(e?.response?.data?.detail || e?.message || "一键入库失败"); + } finally { + setApplying(false); + } + }, [historyId, report, sessionId]); + + const rejectDraft = useCallback(async () => { + setRejecting(true); + try { + const res = await aiAnalysisApi.rejectFailureReason({ + history_id: historyId, + session_id: sessionId, + analysis_draft_id: report?.analysis_draft_id, + }); + message.success(res.message || "已拒绝本次分析结果"); + setStatus("idle"); + setReport(null); + setProgressEvents([]); + setErrorMessage(""); + } catch (err) { + const e = err as { response?: { data?: { detail?: string } }; message?: string }; + message.error(e?.response?.data?.detail || e?.message || "拒绝失败"); + } finally { + setRejecting(false); + } + }, [historyId, report?.analysis_draft_id, sessionId]); + + const resetState = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + setStatus("idle"); + setProgressEvents([]); + setReport(null); + setErrorMessage(""); + setSessionId(newSessionId()); + }, []); + + return { + status, + sessionId, + progressEvents, + report, + errorMessage, + applying, + rejecting, + startInitialAnalyze, + retryAnalyze, + applyFailureReason, + rejectDraft, + resetState, + }; +} diff --git a/frontend/src/services/aiAnalysisService.ts b/frontend/src/services/aiAnalysisService.ts new file mode 100644 index 0000000..31c8799 --- /dev/null +++ b/frontend/src/services/aiAnalysisService.ts @@ -0,0 +1,215 @@ +import request from "./request"; + +export type AnalyzeMode = "initial" | "follow_up"; + +export interface AnalyzeRequestPayload { + history_id: number; + mode: AnalyzeMode; + session_id?: string; + follow_up_message?: string; +} + +export interface ProgressEventPayload { + stage: string; + message: string; + elapsed_ms?: number; + percent?: number; + detail?: string; +} + +export interface AiAnalysisReport { + failure_category?: string; + verdict?: string; + confidence?: number; + summary?: string; + detailed_reason?: string; + data_gaps?: string[]; + suggested_next_steps?: string[]; + stage_timeline?: Array<{ stage?: string; message?: string; elapsed_ms?: number }>; +} + +export interface AiAnalysisReportEnvelope { + session_id?: string; + status?: "ok" | "partial" | "error" | string; + report?: AiAnalysisReport; + trace?: { + skills_invoked?: string[]; + llm_input_tokens?: number; + llm_output_tokens?: number; + elapsed_ms?: number; + }; + analysis_draft_id?: string; +} + +export interface StreamAnalyzeHandlers { + onProgress?: (payload: ProgressEventPayload) => void; + onReport?: (payload: AiAnalysisReportEnvelope) => void; + onError?: (message: string) => void; +} + +function parseEventBlock(block: string): { eventName: string; data: string } | null { + const trimmed = block.trim(); + if (!trimmed) return null; + + const lines = trimmed.split("\n"); + let eventName = "message"; + const dataLines: string[] = []; + + lines.forEach((line) => { + if (line.startsWith("event:")) { + eventName = line.slice("event:".length).trim() || "message"; + return; + } + if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trim()); + } + }); + + return { + eventName, + data: dataLines.join("\n"), + }; +} + +function toErrorMessage(raw: unknown, fallback: string): string { + if (typeof raw === "string" && raw.trim()) return raw.trim(); + if (raw && typeof raw === "object") { + const maybeMessage = (raw as { message?: unknown; detail?: unknown }).message; + if (typeof maybeMessage === "string" && maybeMessage.trim()) return maybeMessage.trim(); + const maybeDetail = (raw as { detail?: unknown }).detail; + if (typeof maybeDetail === "string" && maybeDetail.trim()) return maybeDetail.trim(); + } + return fallback; +} + +export async function streamAnalyze( + payload: AnalyzeRequestPayload, + handlers: StreamAnalyzeHandlers, + signal?: AbortSignal, +): Promise { + const token = localStorage.getItem("token"); + const headers: Record = { + "Content-Type": "application/json", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch("/api/v1/ai/analyze", { + method: "POST", + headers, + body: JSON.stringify(payload), + signal, + }); + + if (!response.ok) { + let msg = `分析请求失败(HTTP ${response.status})`; + try { + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + const body = (await response.json()) as { detail?: unknown }; + msg = toErrorMessage(body.detail, msg); + } else { + const text = await response.text(); + msg = toErrorMessage(text, msg); + } + } catch { + // 保持默认错误文案 + } + throw new Error(msg); + } + + if (!response.body) { + throw new Error("分析服务未返回流式数据"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + let hasFinalEvent = false; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; + + buffer += decoder.decode(value, { stream: true }); + const chunks = buffer.split("\n\n"); + buffer = chunks.pop() ?? ""; + + for (const chunk of chunks) { + const parsed = parseEventBlock(chunk); + if (!parsed || !parsed.data) continue; + + let dataObj: unknown = parsed.data; + try { + dataObj = JSON.parse(parsed.data); + } catch { + // 允许非 JSON 场景,后续按字符串处理 + } + + if (parsed.eventName === "progress") { + handlers.onProgress?.(dataObj as ProgressEventPayload); + continue; + } + if (parsed.eventName === "report") { + handlers.onReport?.(dataObj as AiAnalysisReportEnvelope); + hasFinalEvent = true; + continue; + } + if (parsed.eventName === "error") { + handlers.onError?.(toErrorMessage(dataObj, "AI 分析失败,请稍后重试")); + hasFinalEvent = true; + } + } + } + + if (!hasFinalEvent) { + throw new Error("分析流已结束,但未收到最终结果"); + } +} + +export interface ApplyFailureReasonRequestPayload { + history_id: number; + failure_category: string; + detailed_reason: string; + session_id?: string; + analysis_draft_id?: string; + version?: string; + nonce?: string; +} + +export interface ApplyFailureReasonResponsePayload { + success: boolean; + history_id: number; + applied: boolean; + analyzed_updated: boolean; + message: string; +} + +export interface RejectFailureReasonRequestPayload { + history_id: number; + session_id?: string; + analysis_draft_id?: string; + reason?: string; +} + +export interface RejectFailureReasonResponsePayload { + success: boolean; + history_id: number; + rejected: boolean; + message: string; +} + +export const aiAnalysisApi = { + applyFailureReason( + data: ApplyFailureReasonRequestPayload, + ): Promise { + return request.post("/ai/apply-failure-reason", data) as Promise; + }, + rejectFailureReason( + data: RejectFailureReasonRequestPayload, + ): Promise { + return request.post("/ai/reject-failure-reason", data) as Promise; + }, +};