diff --git a/.env.example b/.env.example index 796640b..a97eaf8 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ DATABASE_URL= SECURITY_DATABASE_URL= # 仅供 Alembic/受控维护命令使用,不得注入后端或 Celery 常规进程。 MIGRATION_DATABASE_URL= +# 仅注入执行 retrain_model 的 Worker;必须是 beta10d_forecast_login 受限 PostgreSQL 身份。 +# 该身份只读训练事实表,并只允许向 model_registry 插入 Candidate,不得使用应用或迁移身份替代。 +MODEL_TRAINING_DATABASE_URL= DATABASE_PRIMARY=postgresql # Compatibility fallback is opt-in for legacy import/debug only. Normal development and production keep 0. DATABASE_ALLOW_LEGACY_FALLBACK=0 diff --git a/.gitignore b/.gitignore index 8994d9d..53a7e1f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ docx_render_check_*/ .codex_tmp/ .codex_envs/ backups/ +docs/codex/evidence/ wheelhouse/ browser_cache/ .browser_cache/ @@ -59,6 +60,7 @@ frontend/.vite/ /*-snapshot.md /*-console.txt /*-layout-check.json +/备-AGENTS.md # Large local model/data artifacts *.zip @@ -76,6 +78,7 @@ model_artifacts/*/* !model_artifacts/*/thresholds.json !model_artifacts/*/training_config.json /model_artifacts/model_day6b_*/ +/model_artifacts/model_run_*/ *.db *.sqlite *.sqlite3 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..7b75d80 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.13.0-rc1 diff --git a/backend/app/ai_assistant/attachments.py b/backend/app/ai_assistant/attachments.py index a853290..26a5ee2 100644 --- a/backend/app/ai_assistant/attachments.py +++ b/backend/app/ai_assistant/attachments.py @@ -54,7 +54,9 @@ def _safe_name(value: str) -> str: def _scope(identity: IdentityContext) -> Path: identity.require_valid(require_session=False) - digest = hashlib.sha256(f"{identity.tenant_id}\0{identity.user_id}".encode()).hexdigest()[:32] + digest = hashlib.sha256( + f"{identity.tenant_id}\0{identity.workspace_id}\0{identity.user_id}".encode() + ).hexdigest()[:32] return ROOT / digest @@ -212,7 +214,8 @@ def upload_attachment(identity: IdentityContext, *, session_id: str, file_name: "media_type": declared, "size_bytes": len(content), "sha256": hashlib.sha256(content).hexdigest(), "status": "uploading", "parser": None, "created_at": now.isoformat(), "expires_at": (now + timedelta(hours=int(os.environ.get("AI_ATTACHMENT_TTL_HOURS", "24")))).isoformat(), - "tenant_id": identity.tenant_id, "user_id": identity.user_id, "error": None, "chunks": [], "properties": {}, + "tenant_id": identity.tenant_id, "workspace_id": identity.workspace_id, + "user_id": identity.user_id, "error": None, "chunks": [], "properties": {}, } data_path.parent.mkdir(parents=True, exist_ok=True) data_path.write_bytes(content) @@ -237,14 +240,26 @@ def upload_attachment(identity: IdentityContext, *, session_id: str, file_name: raise -def get_attachment(identity: IdentityContext, attachment_id: str, *, session_id: str | None = None) -> dict[str, Any]: +def _require_session_id(session_id: str) -> str: + normalized = str(session_id or "").strip() + if not SESSION_RE.fullmatch(normalized): + raise AttachmentError("VALIDATION_FAILED", "session_id 无效。", status_code=422) + return normalized + + +def get_attachment(identity: IdentityContext, attachment_id: str, *, session_id: str) -> dict[str, Any]: + session_id = _require_session_id(session_id) data_path, meta_path = _paths(identity, attachment_id) if not meta_path.is_file(): raise AttachmentError("ATTACHMENT_NOT_FOUND", "附件不存在。", status_code=404) meta = json.loads(meta_path.read_text(encoding="utf-8")) - if meta.get("tenant_id") != identity.tenant_id or meta.get("user_id") != identity.user_id: + if ( + meta.get("tenant_id") != identity.tenant_id + or meta.get("workspace_id") != identity.workspace_id + or meta.get("user_id") != identity.user_id + ): raise AttachmentError("PERMISSION_DENIED", "无权访问该附件。", status_code=403) - if session_id and meta.get("session_id") != session_id: + if meta.get("session_id") != session_id: raise AttachmentError("PERMISSION_DENIED", "附件不属于当前会话。", status_code=403) if datetime.fromisoformat(meta["expires_at"]) <= datetime.now(timezone.utc) and meta.get("status") not in TERMINAL_STATUSES: _discard_attachment_payload( @@ -257,8 +272,8 @@ def get_attachment(identity: IdentityContext, attachment_id: str, *, session_id: return meta -def delete_attachment(identity: IdentityContext, attachment_id: str) -> dict[str, Any]: - meta = get_attachment(identity, attachment_id) +def delete_attachment(identity: IdentityContext, attachment_id: str, *, session_id: str) -> dict[str, Any]: + meta = get_attachment(identity, attachment_id, session_id=session_id) data_path, meta_path = _paths(identity, attachment_id) if meta.get("status") not in {"cancelled", "deleted"}: terminal_status = "cancelled" if meta.get("status") in {"uploading", "parsing"} else "deleted" diff --git a/backend/app/ai_assistant/capability_registry.py b/backend/app/ai_assistant/capability_registry.py index ee92b2e..c770c03 100644 --- a/backend/app/ai_assistant/capability_registry.py +++ b/backend/app/ai_assistant/capability_registry.py @@ -116,6 +116,9 @@ def fallback_capability(primary: ModelCapability) -> ModelCapability | None: "data.export": "data:export", "strategy.generate": "strategy:generate", "report.generate": "report:generate", + "report.approve": "report:review", + "report.reject": "report:review", + "report.publish": "report:publish", "settings.write": "settings:write", "assistant.use": "assistant:use", "assistant.export": "assistant:export", @@ -128,7 +131,7 @@ def capability_manifest(permissions: list[str]) -> dict[str, Any]: return { "routes": {key: permits(value) for key, value in ROUTE_PERMISSIONS.items()}, "actions": {key: permits(value) for key, value in ACTION_PERMISSIONS.items()}, - "policy_version": "v2.12.0", + "policy_version": "v2.13.0-rc1", } diff --git a/backend/app/api/v1/endpoints/assistant.py b/backend/app/api/v1/endpoints/assistant.py index 437ca31..1ca83b6 100644 --- a/backend/app/api/v1/endpoints/assistant.py +++ b/backend/app/api/v1/endpoints/assistant.py @@ -2,8 +2,11 @@ import io import json +import os import time import uuid +from datetime import datetime, timezone +from pathlib import Path from typing import Annotated, Any, Iterator from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Request, UploadFile @@ -60,6 +63,14 @@ router = APIRouter() +MAX_EXPORT_MESSAGES = 500 +MAX_EXPORT_CHARS = 500_000 + + +class PDFExportUnavailableError(RuntimeError): + pass + + def _identity(user: CurrentUser, session_id: str = "", run_id: str = "latest") -> IdentityContext: return IdentityContext.from_user(user, session_id=session_id, run_id=run_id) @@ -96,12 +107,22 @@ def _chunk_text(text: str, chunk_size: int = 36) -> Iterator[str]: def _format_messages_for_export(messages: list[dict[str, Any]]) -> list[tuple[str, str, str]]: + if not isinstance(messages, list): + raise ValueError("messages 必须是数组。") + if len(messages) > MAX_EXPORT_MESSAGES: + raise ValueError(f"单次最多导出 {MAX_EXPORT_MESSAGES} 条消息。") rows: list[tuple[str, str, str]] = [] + total_chars = 0 for item in messages: + if not isinstance(item, dict) or item.get("role") not in {"user", "assistant"}: + continue role = "用户" if item.get("role") == "user" else "AI 助手" - created_at = str(item.get("created_at") or item.get("createdAt") or "") + created_at = str(mask_secret_fields(str(item.get("created_at") or item.get("createdAt") or "")))[:80] content = str(mask_secret_fields(str(item.get("content") or ""))).strip() if content: + total_chars += len(content) + if total_chars > MAX_EXPORT_CHARS: + raise ValueError(f"单次导出正文最多 {MAX_EXPORT_CHARS} 个字符。") rows.append((role, created_at, content)) return rows @@ -127,6 +148,132 @@ def _build_conversation_docx(payload: dict[str, Any]) -> bytes: return buffer.getvalue() +def _build_conversation_pdf(payload: dict[str, Any]) -> bytes: + import fitz + + rows = _format_messages_for_export(payload.get("messages") or []) + font_candidates = [ + os.environ.get("AI_PDF_FONT_FILE", ""), + r"C:\Windows\Fonts\simhei.ttf", + r"C:\Windows\Fonts\msyh.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf", + ] + font_file = next((str(Path(item)) for item in font_candidates if item and Path(item).is_file()), "") + if not font_file: + raise PDFExportUnavailableError("未找到可嵌入的中文 PDF 字体。") + document = fitz.open() + page_width, page_height = fitz.paper_size("a4") + margin_x, top_margin, bottom_margin = 48.0, 48.0, 46.0 + content_width = page_width - (2 * margin_x) + font_name = "assistantcjk" + font = fitz.Font(fontfile=font_file) + page: fitz.Page | None = None + cursor_y = top_margin + + def new_page(*, continued: bool = False) -> None: + nonlocal page, cursor_y + page = document.new_page(width=page_width, height=page_height) + page.insert_font(fontname=font_name, fontfile=font_file) + cursor_y = top_margin + if continued: + page.insert_text( + (margin_x, cursor_y), "AI 助手会话导出(续)", + fontname=font_name, fontsize=10.5, color=(0.32, 0.38, 0.46), + ) + cursor_y += 24 + + def ensure_space(height: float) -> None: + if page is None or cursor_y + height > page_height - bottom_margin: + new_page(continued=page is not None) + + def wrap_line(value: str, font_size: float, width: float) -> list[str]: + if not value: + return [""] + lines: list[str] = [] + current = "" + for character in value.expandtabs(4): + candidate = current + character + if current and font.text_length(candidate, fontsize=font_size) > width: + lines.append(current.rstrip()) + current = character.lstrip() if character.isspace() else character + else: + current = candidate + lines.append(current.rstrip()) + return lines + + def write_text(value: str, *, font_size: float = 10.5, line_height: float = 16.0, + color: tuple[float, float, float] = (0.12, 0.15, 0.2), + indent: float = 0.0) -> None: + nonlocal cursor_y + paragraphs = value.splitlines() or [value] + for paragraph_index, paragraph in enumerate(paragraphs): + lines = wrap_line(paragraph, font_size, content_width - indent) or [""] + for line in lines: + ensure_space(line_height) + assert page is not None + if line: + page.insert_text( + (margin_x + indent, cursor_y), line, + fontname=font_name, fontsize=font_size, color=color, + ) + cursor_y += line_height + if paragraph_index < len(paragraphs) - 1: + cursor_y += 3 + + new_page() + assert page is not None + page.insert_text( + (margin_x, cursor_y), "AI 助手会话导出", + fontname=font_name, fontsize=19, color=(0.07, 0.22, 0.43), + ) + cursor_y += 32 + session_id = str(mask_secret_fields(str(payload.get("session_id") or "local_session")))[:160] + exported_at = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S %z") + write_text(f"会话 ID:{session_id}", font_size=9.5, line_height=14, color=(0.34, 0.39, 0.46)) + write_text(f"导出时间:{exported_at}", font_size=9.5, line_height=14, color=(0.34, 0.39, 0.46)) + write_text( + "说明:本文件由后端导出接口生成,内容来自当前会话消息,不包含隐藏调试 Trace。", + font_size=9.5, line_height=14, color=(0.34, 0.39, 0.46), + ) + cursor_y += 12 + page.draw_line((margin_x, cursor_y), (page_width - margin_x, cursor_y), color=(0.8, 0.84, 0.89), width=0.8) + cursor_y += 22 + + if not rows: + write_text("当前会话没有可导出的消息。", color=(0.38, 0.42, 0.48)) + for role, created_at, content in rows: + ensure_space(42) + assert page is not None + heading = f"{role}{f' · {created_at}' if created_at else ''}" + page.insert_text( + (margin_x, cursor_y), heading, + fontname=font_name, fontsize=11.5, + color=(0.1, 0.36, 0.62) if role == "用户" else (0.12, 0.48, 0.37), + ) + cursor_y += 20 + write_text(content, indent=8) + cursor_y += 12 + + for page_number, export_page in enumerate(document, start=1): + footer = f"第 {page_number} / {document.page_count} 页" + footer_width = font.text_length(footer, fontsize=8.5) + export_page.insert_text( + ((page_width - footer_width) / 2, page_height - 22), footer, + fontname=font_name, fontsize=8.5, color=(0.45, 0.49, 0.55), + ) + document.set_metadata({ + "title": "AI 助手会话导出", + "author": "智能运营分析平台", + "subject": "当前会话消息导出", + }) + try: + document.subset_fonts(fallback=True) + return document.tobytes(garbage=4, deflate=True) + finally: + document.close() + + def _enterprise_runtime(user: CurrentUser) -> dict[str, Any]: if not enterprise_mode(): return {} @@ -372,7 +519,7 @@ def _answer_chat_from_payload( if not attachments["images"]: raise HTTPException(status_code=422, detail={"code": "VISION_ATTACHMENT_REQUIRED"}) return _vision_answer(payload, route, alias, attachments) - if route == AssistantRoute.CHATBI and payload.mode == "chatbi": + if route == AssistantRoute.CHATBI: session_id = payload.session_id or f"sess_{uuid.uuid4().hex}" identity = IdentityContext.from_user( user, session_id=session_id, run_id=f"run_chatbi_{uuid.uuid4().hex}", agent_id="chatbi" @@ -387,21 +534,35 @@ def _answer_chat_from_payload( ) except ChatBIServiceError as exc: raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc - narrative = result.get("narrative") or {} - answer = str(narrative.get("summary") or narrative.get("conclusion") or result.get("state") or "") - return _finalize_contract(payload, route, alias, attachments, { + answer = str(result.get("answer") or "").strip() + if not answer or answer.casefold() in { + "success", "empty", "insufficient_data", "clarification_required", "unavailable", + }: + raise HTTPException( + status_code=503, + detail={ + "code": "CHATBI_ANSWER_CONTRACT_INVALID", + "message": "ChatBI 未生成可展示的最终回答。", + }, + ) + response_payload = { **result, "session_id": session_id, "trace_id": f"trace_{uuid.uuid4().hex}", "answer": answer, - "citations": [], + "citations": list((result.get("narrative") or {}).get("citations") or []), "grounding_status": "grounded" if result.get("result_dataset") else "unavailable", "route": { "selected_provider": (result.get("planner") or {}).get("provider"), "selected_model": (result.get("planner") or {}).get("model"), "fallback_used": bool((result.get("planner") or {}).get("fallback")), }, - }) + } + if result.get("state") == "unavailable": + response_payload["unavailable_reason"] = str( + (result.get("error") or {}).get("code") or "chatbi_unavailable" + ) + return _finalize_contract(payload, route, alias, attachments, response_payload) runtime = _assistant_runtime(route, user, payload.knowledge_scope) try: response = _answer_contract(answer_chat( @@ -608,11 +769,11 @@ async def ai_upload_attachment( def ai_get_attachment( attachment_id: str, user: Annotated[CurrentUser, Depends(require_permission("assistant:use"))], - session_id: str | None = None, + session_id: str, ) -> dict: try: return {key: value for key, value in get_attachment( - _identity(user, session_id or ""), attachment_id, session_id=session_id + _identity(user, session_id), attachment_id, session_id=session_id ).items() if key in { "attachment_id", "session_id", "file_name", "media_type", "size_bytes", "sha256", "status", "parser", "created_at", "expires_at", "error", "prompt_injection_detected", @@ -627,9 +788,10 @@ def ai_delete_attachment( attachment_id: str, request: Request, user: Annotated[CurrentUser, Depends(require_permission("assistant:use"))], + session_id: str, ) -> dict: try: - record = delete_attachment(_identity(user), attachment_id) + record = delete_attachment(_identity(user, session_id), attachment_id, session_id=session_id) write_audit_log( action="ai.attachment.delete", user=user, @@ -649,7 +811,7 @@ def ai_delete_attachment( status="denied" if exc.code == "PERMISSION_DENIED" else "failed", request_id=request.headers.get("X-Request-ID", ""), ip_address=request.client.host if request.client else "", - metadata={"error_code": exc.code}, + metadata={"session_id": session_id, "error_code": exc.code}, ) _raise_attachment_http(exc) raise AssertionError("unreachable") @@ -662,16 +824,22 @@ def ai_export_conversation( format: str = "docx", ) -> Response: normalized_format = format.lower().strip() - if normalized_format == "pdf": - raise HTTPException(status_code=501, detail="PDF 导出引擎尚未部署,请先使用 Word 导出。") - if normalized_format != "docx": + if normalized_format not in {"docx", "pdf"}: raise HTTPException(status_code=400, detail="仅支持 docx 或 pdf 导出格式。") - - content = _build_conversation_docx(payload) - filename = f"assistant_conversation_{int(time.time())}.docx" + try: + content = _build_conversation_pdf(payload) if normalized_format == "pdf" else _build_conversation_docx(payload) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except PDFExportUnavailableError as exc: + raise HTTPException(status_code=503, detail="PDF 导出运行时不可用。") from exc + filename = f"assistant_conversation_{int(time.time())}.{normalized_format}" + media_type = ( + "application/pdf" if normalized_format == "pdf" + else "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) return Response( content, - media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + media_type=media_type, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) diff --git a/backend/app/api/v1/endpoints/model.py b/backend/app/api/v1/endpoints/model.py index 8acbfc8..c61c870 100644 --- a/backend/app/api/v1/endpoints/model.py +++ b/backend/app/api/v1/endpoints/model.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Annotated -from fastapi import APIRouter, Depends, Query, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import BaseModel from ....core.config import PROJECT_ROOT @@ -52,6 +52,7 @@ class ModelActionPayload(BaseModel): region: str = "浙江省" domain: str = "price" target_name: str = "da_price" + rolling_backtest: bool = False def _payload_dict(payload: BaseModel) -> dict: @@ -310,9 +311,12 @@ def models_center_version_detail( @router.post("/api/models/center/training/start") def models_center_training_start( payload: ModelActionPayload, - user: Annotated[CurrentUser, Depends(require_permission("task:run"))], + user: Annotated[CurrentUser, Depends(require_permission("model:manage"))], ) -> dict: - result = start_model_training(user=user, payload=_payload_dict(payload)) + try: + result = start_model_training(user=user, payload=_payload_dict(payload)) + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc write_audit_log( action="model.training.start", user=user, @@ -326,7 +330,7 @@ def models_center_training_start( @router.post("/api/models/center/activate") def models_center_activate( payload: ModelActionPayload, - user: Annotated[CurrentUser, Depends(require_permission("task:run"))], + user: Annotated[CurrentUser, Depends(require_permission("model:manage"))], ) -> dict: result = activate_model_version( payload.version or "", @@ -343,13 +347,15 @@ def models_center_activate( status="success" if result.get("success") else "failed", metadata=result, ) + if not result.get("success"): + raise HTTPException(status_code=409, detail=result) return result @router.post("/api/models/center/rollback") def models_center_rollback( payload: ModelActionPayload, - user: Annotated[CurrentUser, Depends(require_permission("task:run"))], + user: Annotated[CurrentUser, Depends(require_permission("model:manage"))], ) -> dict: result = rollback_model_version( payload.version or "", @@ -366,6 +372,8 @@ def models_center_rollback( status="success" if result.get("success") else "failed", metadata=result, ) + if not result.get("success"): + raise HTTPException(status_code=409, detail=result) return result diff --git a/backend/app/api/v1/endpoints/report.py b/backend/app/api/v1/endpoints/report.py index b361c2c..074d31b 100644 --- a/backend/app/api/v1/endpoints/report.py +++ b/backend/app/api/v1/endpoints/report.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import Annotated +from typing import Annotated, NoReturn from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import FileResponse @@ -12,6 +12,7 @@ from ....repositories.audit_repository import write_audit_log from ....repositories.report_repository import list_reports_from_postgres, report_summary_from_postgres from ....schemas import ReportGenerateRequest, ReviewRequest +from ....services.report_governance_service import ReportGovernanceError from ....source_contract import SourceType, attach_source_meta, source_meta from ....workers.dispatcher import enqueue_task @@ -19,6 +20,13 @@ router = APIRouter() +def _raise_governance_error(exc: ReportGovernanceError) -> NoReturn: + raise HTTPException( + status_code=exc.status_code, + detail={"code": exc.code, "message": str(exc)}, + ) from exc + + def _with_report_meta(payload: dict, *, historical: bool = False) -> dict: items = payload.get("items") or [] available = bool(payload.get("available", payload.get("report_id") or items)) @@ -116,17 +124,25 @@ def report_summary(_: Annotated[CurrentUser, Depends(require_permission("report: @router.get("/api/reports/latest") -def report_latest() -> dict: +def report_latest( + _: Annotated[CurrentUser, Depends(require_permission("report:read"))], +) -> dict: return _with_report_meta(report_status()) @router.get("/api/reports/{report_id}") -def report_detail(report_id: str) -> dict: +def report_detail( + report_id: str, + _: Annotated[CurrentUser, Depends(require_permission("report:read"))], +) -> dict: return _with_report_meta(report_status(report_id), historical=True) @router.get("/api/reports/{report_id}/download") -def report_download(report_id: str): +def report_download( + report_id: str, + _: Annotated[CurrentUser, Depends(require_permission("report:download"))], +): report = report_status(report_id) path = Path(str(report.get("report_path") or "")) if not path.is_file(): @@ -145,8 +161,30 @@ def report_regenerate( request: Request, user: Annotated[CurrentUser, Depends(require_permission("report:generate"))], ) -> dict: + source = report_status(report_id) + if source.get("report_id") != report_id: + raise HTTPException(status_code=404, detail="报告不存在") + if str(source.get("status") or "").lower() != "rejected": + raise HTTPException( + status_code=409, + detail={ + "code": "report_regeneration_requires_rejected", + "message": "只有已驳回报告可以重新生成新版本。", + }, + ) + metadata = source.get("metadata") or {} try: - result = enqueue_task("report_generate", {"report_id": report_id}) + result = enqueue_task( + "report_generate", + { + "run_id": source.get("run_id"), + "report_type": source.get("report_type") or metadata.get("report_type") or "daily", + "region": metadata.get("region") or "模型覆盖市场", + "source_report_id": report_id, + "regenerate": True, + "requested_by": user.username, + }, + ) except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc write_audit_log( @@ -155,9 +193,14 @@ def report_regenerate( resource_type="report", resource_id=report_id, ip_address=request.client.host if request.client else "", - metadata=result, + metadata={"source_report_id": report_id, "task": result}, ) - return result + return { + **result, + "accepted": True, + "source_report_id": report_id, + "operation": "report_regenerate_new_version", + } @router.post("/api/reports/{report_id}/approve") @@ -167,8 +210,17 @@ def report_approve( request: Request, user: Annotated[CurrentUser, Depends(require_permission("report:review"))], ) -> dict: - result = save_report_review(report_id, "approved", payload.reviewer, payload.review_comment) - write_audit_log( + try: + result = save_report_review( + report_id, + "approved", + payload.reviewer, + payload.review_comment, + actor=user, + ) + except ReportGovernanceError as exc: + _raise_governance_error(exc) + audit_persisted = write_audit_log( action="report.approve", user=user, resource_type="report", @@ -176,7 +228,7 @@ def report_approve( ip_address=request.client.host if request.client else "", metadata={"reviewer": payload.reviewer, "result": result}, ) - return result + return {**result, "audit_log_persisted": audit_persisted} @router.post("/api/reports/{report_id}/reject") @@ -186,10 +238,17 @@ def report_reject( request: Request, user: Annotated[CurrentUser, Depends(require_permission("report:review"))], ) -> dict: - if not payload.review_comment.strip(): - raise HTTPException(status_code=400, detail="驳回报告必须填写审核意见") - result = save_report_review(report_id, "rejected", payload.reviewer, payload.review_comment) - write_audit_log( + try: + result = save_report_review( + report_id, + "rejected", + payload.reviewer, + payload.review_comment, + actor=user, + ) + except ReportGovernanceError as exc: + _raise_governance_error(exc) + audit_persisted = write_audit_log( action="report.reject", user=user, resource_type="report", @@ -198,7 +257,7 @@ def report_reject( ip_address=request.client.host if request.client else "", metadata={"reviewer": payload.reviewer, "comment": payload.review_comment, "result": result}, ) - return result + return {**result, "audit_log_persisted": audit_persisted} @router.post("/api/reports/{report_id}/publish") @@ -206,10 +265,19 @@ def report_publish( report_id: str, payload: ReviewRequest, request: Request, - user: Annotated[CurrentUser, Depends(require_permission("report:review"))], + user: Annotated[CurrentUser, Depends(require_permission("report:publish"))], ) -> dict: - result = save_report_review(report_id, "published", payload.reviewer, payload.review_comment) - write_audit_log( + try: + result = save_report_review( + report_id, + "published", + payload.reviewer, + payload.review_comment, + actor=user, + ) + except ReportGovernanceError as exc: + _raise_governance_error(exc) + audit_persisted = write_audit_log( action="report.publish", user=user, resource_type="report", @@ -217,9 +285,16 @@ def report_publish( ip_address=request.client.host if request.client else "", metadata={"reviewer": payload.reviewer, "result": result}, ) - return result + return {**result, "audit_log_persisted": audit_persisted} @router.get("/api/reports/{report_id}/reviews") -def report_reviews(report_id: str) -> dict: - return {"report_id": report_id, "reviews": list_report_reviews(report_id)} +def report_reviews( + report_id: str, + _: Annotated[CurrentUser, Depends(require_permission("report:review"))], +) -> dict: + try: + reviews = list_report_reviews(report_id) + except ReportGovernanceError as exc: + _raise_governance_error(exc) + return {"report_id": report_id, "reviews": reviews} diff --git a/backend/app/api/v1/endpoints/system.py b/backend/app/api/v1/endpoints/system.py index 1915fd6..3135df9 100644 --- a/backend/app/api/v1/endpoints/system.py +++ b/backend/app/api/v1/endpoints/system.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status -from ....config import APP_VERSION, PLATFORM_NAME +from ....config import PLATFORM_NAME from ....core.security import CurrentUser, ROLE_PERMISSIONS, get_current_user, require_permission from ....data_access import latest_business_summary from ....auth.password import hash_password @@ -34,6 +34,7 @@ from backend.app.ai_assistant.core.llm_client import get_local_llm_status from backend.app.ai_assistant.capability_registry import capability_manifest from ....services.rag_runtime_warmup import runtime_warmup_status +from ....runtime_identity import runtime_identity_payload router = APIRouter() @@ -44,7 +45,7 @@ def health() -> dict: return { "ok": True, "platform": PLATFORM_NAME, - "version": APP_VERSION, + **runtime_identity_payload(), "rag_runtime_warmup": runtime_warmup_status(), } diff --git a/backend/app/api/v1/endpoints/task.py b/backend/app/api/v1/endpoints/task.py index 8e932d0..75fbf53 100644 --- a/backend/app/api/v1/endpoints/task.py +++ b/backend/app/api/v1/endpoints/task.py @@ -8,7 +8,6 @@ from ....repositories.audit_repository import write_audit_log from ....repositories.task_repository import ( append_task_log, - create_pending_task_record, get_task_record, list_recent_tasks, list_task_log_entries, @@ -186,37 +185,25 @@ def task_start( task_payload = dict(payload.get("payload") or {}) task_payload.setdefault("created_by", getattr(user, "username", "web_user")) dispatch_error = "" - can_dispatch = kind in {"knowledge_import", "embedding_refresh", "report_generate", "sync_core_data"} | BUSINESS_TASK_KINDS + can_dispatch = kind in { + "knowledge_import", + "embedding_refresh", + "report_generate", + "sync_core_data", + "health_check", + } | BUSINESS_TASK_KINDS if not can_dispatch: try: command_for_kind(kind) can_dispatch = True except Exception as exc: dispatch_error = str(exc) - if can_dispatch: - try: - result = enqueue_task(kind, task_payload) - except Exception as exc: - dispatch_error = str(exc) - result = create_pending_task_record( - kind=kind, - task_name=str(payload.get("task_name") or kind), - payload=task_payload, - queue_name=str(payload.get("queue_name") or ""), - execution_mode="db_pending", - created_by=getattr(user, "username", "web_user"), - ) - else: - result = create_pending_task_record( - kind=kind, - task_name=str(payload.get("task_name") or kind), - payload=task_payload, - queue_name=str(payload.get("queue_name") or ""), - execution_mode="db_pending", - created_by=getattr(user, "username", "web_user"), + if not can_dispatch: + raise HTTPException( + status_code=422, + detail={"code": "TASK_KIND_NOT_EXECUTABLE", "message": dispatch_error or kind}, ) - if dispatch_error: - result["dispatch_fallback_reason"] = dispatch_error[:300] + result = _enqueue_or_503(kind, task_payload) write_audit_log( action="task.start", user=user, @@ -364,19 +351,7 @@ def task_retry( payload["original_task_id"] = record.get("original_task_id") or task_id payload["retry_count"] = retry_count + 1 payload["force_new"] = True - try: - result = enqueue_task(kind, payload) - except Exception as exc: - result = create_pending_task_record( - kind=kind, - task_name=str(record.get("task_name") or kind), - payload=payload, - queue_name=str(record.get("queue_name") or ""), - execution_mode="db_pending_retry", - created_by=getattr(user, "username", "web_user"), - retry_of=task_id, - ) - result["dispatch_fallback_reason"] = str(exc)[:300] + result = _enqueue_or_503(kind, payload) append_task_log( task_id, level="info", diff --git a/backend/app/chatbi/service.py b/backend/app/chatbi/service.py index c3411e0..2d6337f 100644 --- a/backend/app/chatbi/service.py +++ b/backend/app/chatbi/service.py @@ -164,14 +164,20 @@ def execute_chatbi_analysis( raise ChatBIServiceError("analysis_audit_unavailable", "分析审计记录不可用。", status_code=503) from exc if not validation.valid: if validation.status == "clarification_required": + clarification_question = ( + server_plan.clarification_question + or "请补充要分析的业务指标、时间范围或比较对象。" + ) return { "available": False, "state": "clarification_required", + "answer": clarification_question, + "answer_source": "clarification", "analysis_plan": server_plan.model_dump(mode="json"), "validation": validation.public_dict(), "clarification": { "required": True, - "question": server_plan.clarification_question, + "question": clarification_question, }, "lineage": { "analysis_plan_id": server_plan.analysis_plan_id, @@ -186,14 +192,17 @@ def execute_chatbi_analysis( } if not generated_plan: raise ChatBIServiceError("analysis_plan_invalid", "AnalysisPlan 未通过验证。", status_code=422) + unavailable_message = "当前分析条件未能形成可执行计划,请补充指标、时间范围或比较对象后重试。" return { "available": False, "state": "unavailable", + "answer": unavailable_message, + "answer_source": "error", "analysis_plan": server_plan.model_dump(mode="json"), "validation": validation.public_dict(), "error": { "code": "PLANNER_INVALID", - "message": "当前分析条件未能形成可执行计划,请补充指标、时间范围或比较对象后重试。", + "message": unavailable_message, }, "lineage": { "analysis_plan_id": server_plan.analysis_plan_id, @@ -230,6 +239,8 @@ def execute_chatbi_analysis( return { "available": result.state == "success", "state": result.state, + "answer": narrative.text, + "answer_source": "grounded_narrative", "analysis_plan": server_plan.model_dump(mode="json"), "validation": validation.public_dict(), "result_dataset": result.model_dump(mode="json", by_alias=True), diff --git a/backend/app/config.py b/backend/app/config.py index 49689d6..db25ff2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,14 @@ from automation_common import get_pipeline_paths, load_config # noqa: E402 -APP_VERSION = "v2.11.2" +def _load_app_version() -> str: + raw = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip() + if not raw: + raise RuntimeError("VERSION 文件不能为空。") + return raw if raw.startswith("v") else f"v{raw}" + + +APP_VERSION = _load_app_version() PLATFORM_NAME = "售电交易 AI 辅助决策平台" diff --git a/backend/app/core/resources/API_PERMISSION_MATRIX.csv b/backend/app/core/resources/API_PERMISSION_MATRIX.csv index 6c03840..bbd8165 100644 --- a/backend/app/core/resources/API_PERMISSION_MATRIX.csv +++ b/backend/app/core/resources/API_PERMISSION_MATRIX.csv @@ -1,27 +1,27 @@ -Method,Path,Module,Handler,Route Name,Response Model,Risk Level,Current Guard,Target Guard,Required Permission,Allowed Roles,Anonymous,Write Operation,Frontend Consumer,Notes +Method,Path,Module,Handler,Route Name,Response Model,Risk Level,Current Guard,Target Guard,Required Permission,Allowed Roles,Anonymous,Write Operation,Frontend Consumer,Notes POST,/api/ai/agent/analyze,backend.app.api.v1.endpoints.assistant,ai_agent_analyze,ai_agent_analyze,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/api.ts:304,none POST,/api/ai/attachments,backend.app.api.v1.endpoints.assistant,ai_upload_attachment,ai_upload_attachment,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/services/assistantApi.ts:224,none -DELETE,/api/ai/attachments/{attachment_id},backend.app.api.v1.endpoints.assistant,ai_delete_attachment,ai_delete_attachment,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/services/assistantApi.ts:254;frontend/src/services/assistantApi.ts:265,same-path methods=DELETE|GET -GET,/api/ai/attachments/{attachment_id},backend.app.api.v1.endpoints.assistant,ai_get_attachment,ai_get_attachment,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,no,frontend/src/services/assistantApi.ts:254;frontend/src/services/assistantApi.ts:265,same-path methods=DELETE|GET +DELETE,/api/ai/attachments/{attachment_id},backend.app.api.v1.endpoints.assistant,ai_delete_attachment,ai_delete_attachment,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/services/assistantApi.ts:254;frontend/src/services/assistantApi.ts:266,same-path methods=DELETE|GET +GET,/api/ai/attachments/{attachment_id},backend.app.api.v1.endpoints.assistant,ai_get_attachment,ai_get_attachment,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,no,frontend/src/services/assistantApi.ts:254;frontend/src/services/assistantApi.ts:266,same-path methods=DELETE|GET POST,/api/ai/chat,backend.app.api.v1.endpoints.assistant,ai_chat,ai_chat,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/api.ts:302,none POST,/api/ai/chat/feedback,backend.app.api.v1.endpoints.assistant,ai_chat_feedback,ai_chat_feedback,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/api.ts:305,none GET,/api/ai/chat/sessions,backend.app.api.v1.endpoints.assistant,ai_chat_sessions,ai_chat_sessions,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,no,frontend/src/api.ts:307,none -POST,/api/ai/chat/sessions/export,backend.app.api.v1.endpoints.assistant,ai_export_conversation,ai_export_conversation,None,sensitive,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:export,assistant:export,admin|analyst|developer,no,yes,frontend/src/services/assistantApi.ts:285,download/export response -DELETE,/api/ai/chat/sessions/{session_id},backend.app.api.v1.endpoints.assistant,ai_delete_chat_session,ai_delete_chat_session,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/services/assistantApi.ts:285,same-path methods=DELETE|GET|PATCH -GET,/api/ai/chat/sessions/{session_id},backend.app.api.v1.endpoints.assistant,ai_chat_session,ai_chat_session,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,no,frontend/src/services/assistantApi.ts:285,same-path methods=DELETE|GET|PATCH -PATCH,/api/ai/chat/sessions/{session_id},backend.app.api.v1.endpoints.assistant,ai_update_chat_session,ai_update_chat_session,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/services/assistantApi.ts:285,same-path methods=DELETE|GET|PATCH +POST,/api/ai/chat/sessions/export,backend.app.api.v1.endpoints.assistant,ai_export_conversation,ai_export_conversation,None,sensitive,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:export,assistant:export,admin|analyst|developer,no,yes,frontend/src/services/assistantApi.ts:286,download/export response +DELETE,/api/ai/chat/sessions/{session_id},backend.app.api.v1.endpoints.assistant,ai_delete_chat_session,ai_delete_chat_session,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/api.ts:308;frontend/src/services/assistantApi.ts:286,same-path methods=DELETE|GET|PATCH +GET,/api/ai/chat/sessions/{session_id},backend.app.api.v1.endpoints.assistant,ai_chat_session,ai_chat_session,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,no,frontend/src/api.ts:308;frontend/src/services/assistantApi.ts:286,same-path methods=DELETE|GET|PATCH +PATCH,/api/ai/chat/sessions/{session_id},backend.app.api.v1.endpoints.assistant,ai_update_chat_session,ai_update_chat_session,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/api.ts:308;frontend/src/services/assistantApi.ts:286,same-path methods=DELETE|GET|PATCH POST,/api/ai/chat/stream,backend.app.api.v1.endpoints.assistant,ai_chat_stream,ai_chat_stream,None,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/services/assistantApi.ts:190,SSE stream POST,/api/ai/chatbi/analyze,backend.app.api.v1.endpoints.chatbi,analyze_chatbi,analyze_chatbi,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/services/assistantApi.ts:166,none POST,/api/ai/feedback,backend.app.api.v1.endpoints.assistant,ai_answer_feedback,ai_answer_feedback,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,frontend/src/api.ts:306,none -GET,/api/ai/insights,backend.app.api.v1.endpoints.assistant,ai_insights,ai_insights,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,no,frontend/src/api.ts:308,none -GET,/api/ai/local-model/status,backend.app.api.v1.endpoints.system,local_model_status,local_model_status,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:381,none +GET,/api/ai/insights,backend.app.api.v1.endpoints.assistant,ai_insights,ai_insights,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,no,frontend/src/api.ts:309,none +GET,/api/ai/local-model/status,backend.app.api.v1.endpoints.system,local_model_status,local_model_status,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:382,none POST,/api/ai/rag-answer,backend.app.api.v1.endpoints.assistant,ai_rag_answer_read_only,ai_rag_answer_read_only,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,none,none -GET,/api/ai/traces,backend.app.api.v1.endpoints.assistant,ai_traces,ai_traces,,sensitive,auth:get_current_user|permission:trace:read,authenticated+permission:trace:read,trace:read,admin|developer,no,no,frontend/src/api.ts:309,none -GET,/api/ai/traces/{trace_id},backend.app.api.v1.endpoints.assistant,ai_trace_detail,ai_trace_detail,,sensitive,auth:get_current_user|permission:trace:read,authenticated+permission:trace:read,trace:read,admin|developer,no,no,frontend/src/api.ts:310,none +GET,/api/ai/traces,backend.app.api.v1.endpoints.assistant,ai_traces,ai_traces,,sensitive,auth:get_current_user|permission:trace:read,authenticated+permission:trace:read,trace:read,admin|developer,no,no,frontend/src/api.ts:310,none +GET,/api/ai/traces/{trace_id},backend.app.api.v1.endpoints.assistant,ai_trace_detail,ai_trace_detail,,sensitive,auth:get_current_user|permission:trace:read,authenticated+permission:trace:read,trace:read,admin|developer,no,no,frontend/src/api.ts:311,none POST,/api/anomaly/explain,backend.app.api.v1.endpoints.strategy,anomaly_explain,anomaly_explain,,business,auth:get_current_user|permission:task:run,authenticated+permission:strategy:generate,strategy:generate,admin|analyst,no,yes,frontend/src/api.ts:300,none GET,/api/anomaly/latest,backend.app.api.v1.endpoints.strategy,anomaly_latest,anomaly_latest,,business,none,authenticated+permission:strategy:read,strategy:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:299,none GET,/api/anomaly/{run_id},backend.app.api.v1.endpoints.strategy,anomaly_by_run,anomaly_by_run,,business,none,authenticated+permission:strategy:read,strategy:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:299;frontend/src/api.ts:300,none -GET,/api/audit/logs,backend.app.api.v1.endpoints.system,audit_logs,audit_logs,,sensitive,auth:get_current_user|permission:audit:read,authenticated+permission:audit:read,audit:read,admin|developer,no,no,frontend/src/api.ts:415,none +GET,/api/audit/logs,backend.app.api.v1.endpoints.system,audit_logs,audit_logs,,sensitive,auth:get_current_user|permission:audit:read,authenticated+permission:audit:read,audit:read,admin|developer,no,no,frontend/src/api.ts:416,none POST,/api/auth/change-password,backend.app.api.v1.endpoints.auth,change_password,change_password,,business,auth:get_current_user,authenticated+permission:auth:self,auth:self,admin|analyst|viewer|reviewer|developer,no,yes,frontend/src/api.ts:187,none POST,/api/auth/login,backend.app.api.v1.endpoints.auth,login,login,,public,none,explicit-public-whitelist,,anonymous,yes,yes,frontend/src/api.ts:183,none POST,/api/auth/logout,backend.app.api.v1.endpoints.auth,logout,logout,,business,auth:get_current_user,authenticated+permission:auth:self,auth:self,admin|analyst|viewer|reviewer|developer,no,yes,frontend/src/api.ts:185,none @@ -62,28 +62,28 @@ GET,/api/forecast/runs/{run_id}/results,backend.app.api.v1.endpoints.forecast,fo GET,/api/forecast/{run_id},backend.app.api.v1.endpoints.forecast,forecast_by_run,forecast_by_run,,business,none,authenticated+permission:forecast:read,forecast:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:267;frontend/src/api.ts:270;frontend/src/api.ts:278;frontend/src/api.ts:280,none GET,/api/forecast/{run_id}/hour/{hour},backend.app.api.v1.endpoints.forecast,forecast_hour,forecast_hour,,business,none,authenticated+permission:forecast:read,forecast:read,admin|analyst|viewer|reviewer|developer,no,no,none,none GET,/api/health,backend.app.api.v1.endpoints.system,health,health,,public,none,explicit-public-whitelist,,anonymous,yes,no,frontend/src/api.ts:181,none -POST,/api/knowledge/batch-validate,backend.app.api.v1.endpoints.knowledge,batch_validate_knowledge,batch_validate_knowledge,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:write,knowledge:write,admin|reviewer,no,yes,frontend/src/api.ts:397,none +POST,/api/knowledge/batch-validate,backend.app.api.v1.endpoints.knowledge,batch_validate_knowledge,batch_validate_knowledge,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:write,knowledge:write,admin|reviewer,no,yes,frontend/src/api.ts:398,none GET,/api/knowledge/citations/{chunk_id},backend.app.api.v1.endpoints.knowledge,get_knowledge_citation_detail,get_knowledge_citation_detail,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,none,none GET,/api/knowledge/documents,backend.app.api.v1.endpoints.knowledge,get_knowledge_documents,get_knowledge_documents,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,none,none GET,/api/knowledge/documents/{doc_id},backend.app.api.v1.endpoints.knowledge,get_knowledge_document_detail,get_knowledge_document_detail,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,none,none GET,/api/knowledge/documents/{doc_id}/chunks,backend.app.api.v1.endpoints.knowledge,get_knowledge_document_chunks,get_knowledge_document_chunks,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,none,none -POST,/api/knowledge/documents/{doc_id}/reindex,backend.app.api.v1.endpoints.knowledge,reindex_knowledge_document,reindex_knowledge_document,,business,auth:get_current_user|permission:knowledge:write,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,yes,frontend/src/api.ts:406,none -POST,/api/knowledge/embedding-refresh,backend.app.api.v1.endpoints.knowledge,refresh_knowledge_embeddings,refresh_knowledge_embeddings,,business,auth:get_current_user|permission:knowledge:write,authenticated+permission:knowledge:write,knowledge:write,admin|reviewer,no,yes,frontend/src/api.ts:405,none -GET,/api/knowledge/export,backend.app.api.v1.endpoints.knowledge,export_knowledge,export_knowledge,None,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:export,knowledge:export,admin|analyst|developer,no,no,frontend/src/api.ts:403,download/export response -GET,/api/knowledge/health,backend.app.api.v1.endpoints.knowledge,get_rag_health,get_rag_health,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:383,none +POST,/api/knowledge/documents/{doc_id}/reindex,backend.app.api.v1.endpoints.knowledge,reindex_knowledge_document,reindex_knowledge_document,,business,auth:get_current_user|permission:knowledge:write,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,yes,frontend/src/api.ts:407,none +POST,/api/knowledge/embedding-refresh,backend.app.api.v1.endpoints.knowledge,refresh_knowledge_embeddings,refresh_knowledge_embeddings,,business,auth:get_current_user|permission:knowledge:write,authenticated+permission:knowledge:write,knowledge:write,admin|reviewer,no,yes,frontend/src/api.ts:406,none +GET,/api/knowledge/export,backend.app.api.v1.endpoints.knowledge,export_knowledge,export_knowledge,None,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:export,knowledge:export,admin|analyst|developer,no,no,frontend/src/api.ts:404,download/export response +GET,/api/knowledge/health,backend.app.api.v1.endpoints.knowledge,get_rag_health,get_rag_health,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:384,none GET,/api/knowledge/health/diagnostics,backend.app.api.v1.endpoints.knowledge,get_rag_health_diagnostics,get_rag_health_diagnostics,,business,auth:get_current_user|permission:knowledge:diagnose,authenticated+permission:knowledge:diagnose,knowledge:diagnose,admin|developer,no,no,none,none -POST,/api/knowledge/index-local,backend.app.api.v1.endpoints.knowledge,index_knowledge,index_knowledge,,business,auth:get_current_user|permission:knowledge:write,authenticated+permission:knowledge:write,knowledge:write,admin|reviewer,no,yes,frontend/src/api.ts:404,none +POST,/api/knowledge/index-local,backend.app.api.v1.endpoints.knowledge,index_knowledge,index_knowledge,,business,auth:get_current_user|permission:knowledge:write,authenticated+permission:knowledge:write,knowledge:write,admin|reviewer,no,yes,frontend/src/api.ts:405,none GET,/api/knowledge/ingestions/{ingestion_id},backend.app.api.v1.endpoints.knowledge,get_knowledge_ingestion,get_knowledge_ingestion,,business,auth:get_current_user|permission:knowledge:write,authenticated+permission:knowledge:write,knowledge:write,admin|reviewer,no,no,none,none -POST,/api/knowledge/qa-test,backend.app.api.v1.endpoints.knowledge,qa_test_knowledge,qa_test_knowledge,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,yes,frontend/src/api.ts:396,none -GET,/api/knowledge/releases,backend.app.api.v1.endpoints.knowledge,get_knowledge_releases,get_knowledge_releases,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:407,same-path methods=GET|POST -POST,/api/knowledge/releases,backend.app.api.v1.endpoints.knowledge,create_knowledge_release,create_knowledge_release,,business,auth:get_current_user|permission:knowledge:publish,authenticated+permission:knowledge:publish,knowledge:publish,admin|reviewer,no,yes,frontend/src/api.ts:407,same-path methods=GET|POST +POST,/api/knowledge/qa-test,backend.app.api.v1.endpoints.knowledge,qa_test_knowledge,qa_test_knowledge,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,yes,frontend/src/api.ts:397,none +GET,/api/knowledge/releases,backend.app.api.v1.endpoints.knowledge,get_knowledge_releases,get_knowledge_releases,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:408,same-path methods=GET|POST +POST,/api/knowledge/releases,backend.app.api.v1.endpoints.knowledge,create_knowledge_release,create_knowledge_release,,business,auth:get_current_user|permission:knowledge:publish,authenticated+permission:knowledge:publish,knowledge:publish,admin|reviewer,no,yes,frontend/src/api.ts:408,same-path methods=GET|POST POST,/api/knowledge/releases/{release_id}/publish,backend.app.api.v1.endpoints.knowledge,publish_knowledge_release,publish_knowledge_release,,business,auth:get_current_user|permission:knowledge:publish,authenticated+permission:knowledge:publish,knowledge:publish,admin|reviewer,no,yes,none,none POST,/api/knowledge/releases/{release_id}/rollback,backend.app.api.v1.endpoints.knowledge,rollback_knowledge_release,rollback_knowledge_release,,business,auth:get_current_user|permission:knowledge:publish,authenticated+permission:knowledge:publish,knowledge:publish,admin|reviewer,no,yes,none,none POST,/api/knowledge/releases/{release_id}/validate,backend.app.api.v1.endpoints.knowledge,validate_knowledge_release,validate_knowledge_release,,business,auth:get_current_user|permission:knowledge:publish,authenticated+permission:knowledge:publish,knowledge:publish,admin|reviewer,no,yes,none,none -GET,/api/knowledge/search,backend.app.api.v1.endpoints.knowledge,search_knowledge,search_knowledge,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:393;frontend/src/api.ts:395,same-path methods=GET|POST -POST,/api/knowledge/search,backend.app.api.v1.endpoints.knowledge,search_knowledge_post,search_knowledge_post,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,yes,frontend/src/api.ts:393;frontend/src/api.ts:395,same-path methods=GET|POST -GET,/api/knowledge/stats,backend.app.api.v1.endpoints.knowledge,get_knowledge_stats,get_knowledge_stats,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:382,none -POST,/api/knowledge/upload,backend.app.api.v1.endpoints.knowledge,upload_knowledge_document,upload_knowledge_document,,business,auth:get_current_user|permission:knowledge:write,authenticated+permission:knowledge:write,knowledge:write,admin|reviewer,no,yes,frontend/src/api.ts:401,none +GET,/api/knowledge/search,backend.app.api.v1.endpoints.knowledge,search_knowledge,search_knowledge,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:394;frontend/src/api.ts:396,same-path methods=GET|POST +POST,/api/knowledge/search,backend.app.api.v1.endpoints.knowledge,search_knowledge_post,search_knowledge_post,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,yes,frontend/src/api.ts:394;frontend/src/api.ts:396,same-path methods=GET|POST +GET,/api/knowledge/stats,backend.app.api.v1.endpoints.knowledge,get_knowledge_stats,get_knowledge_stats,,business,auth:get_current_user|permission:knowledge:read,authenticated+permission:knowledge:read,knowledge:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:383,none +POST,/api/knowledge/upload,backend.app.api.v1.endpoints.knowledge,upload_knowledge_document,upload_knowledge_document,,business,auth:get_current_user|permission:knowledge:write,authenticated+permission:knowledge:write,knowledge:write,admin|reviewer,no,yes,frontend/src/api.ts:402,none GET,/api/load/forecast,backend.app.api.v1.endpoints.forecast,stage1_load_forecast,stage1_load_forecast,,business,none,authenticated+permission:forecast:read,forecast:read,admin|analyst|viewer|reviewer|developer,no,no,none,none GET,/api/market/history,backend.app.api.v1.endpoints.forecast,stage1_market_history,stage1_market_history,,business,none,authenticated+permission:forecast:read,forecast:read,admin|analyst|viewer|reviewer|developer,no,no,none,none POST,/api/model-gateway/chat/completions,backend.model_gateway.router,gateway_chat,gateway_chat,,business,auth:get_current_user|permission:assistant:use,authenticated+permission:assistant:use,assistant:use,admin|analyst|developer,no,yes,none,none @@ -93,70 +93,70 @@ GET,/api/model/explain,backend.app.api.v1.endpoints.forecast,stage1_model_explai GET,/api/model/feature-schema,backend.app.api.v1.endpoints.model,models_feature_schema,models_feature_schema,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,none,compatibility alias of /api/models/feature-schema GET,/api/model/leakage-check,backend.app.api.v1.endpoints.model,models_leakage_check,models_leakage_check,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,none,compatibility alias of /api/models/leakage-check GET,/api/models/active,backend.app.api.v1.endpoints.model,models_active,models_active,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,none,none -GET,/api/models/backtest/summary,backend.app.api.v1.endpoints.model,models_backtest_summary,models_backtest_summary,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:378,compatibility alias of /api/model/backtest/summary -POST,/api/models/center/activate,backend.app.api.v1.endpoints.model,models_center_activate,models_center_activate,,admin,auth:get_current_user|permission:task:run,authenticated+permission:model:manage,model:manage,admin,no,yes,frontend/src/api.ts:375,none -GET,/api/models/center/export,backend.app.api.v1.endpoints.model,models_center_export,models_center_export,None,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:export,model:export,admin|developer,no,no,frontend/src/api.ts:377,download/export response +GET,/api/models/backtest/summary,backend.app.api.v1.endpoints.model,models_backtest_summary,models_backtest_summary,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:379,compatibility alias of /api/model/backtest/summary +POST,/api/models/center/activate,backend.app.api.v1.endpoints.model,models_center_activate,models_center_activate,,admin,auth:get_current_user|permission:model:manage,authenticated+permission:model:manage,model:manage,admin,no,yes,frontend/src/api.ts:376,none +GET,/api/models/center/export,backend.app.api.v1.endpoints.model,models_center_export,models_center_export,None,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:export,model:export,admin|developer,no,no,frontend/src/api.ts:378,download/export response GET,/api/models/center/overview,backend.app.api.v1.endpoints.model,models_center_overview,models_center_overview,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,none,none -POST,/api/models/center/rollback,backend.app.api.v1.endpoints.model,models_center_rollback,models_center_rollback,,admin,auth:get_current_user|permission:task:run,authenticated+permission:model:manage,model:manage,admin,no,yes,frontend/src/api.ts:376,none -POST,/api/models/center/training/start,backend.app.api.v1.endpoints.model,models_center_training_start,models_center_training_start,,admin,auth:get_current_user|permission:task:run,authenticated+permission:model:manage,model:manage,admin,no,yes,frontend/src/api.ts:374,none -GET,/api/models/center/versions/{version},backend.app.api.v1.endpoints.model,models_center_version_detail,models_center_version_detail,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:373,none -GET,/api/models/errors,backend.app.api.v1.endpoints.model,models_errors,models_errors,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:364,none -GET,/api/models/feature-schema,backend.app.api.v1.endpoints.model,models_feature_schema,models_feature_schema,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:379,compatibility alias of /api/model/feature-schema -GET,/api/models/leakage-check,backend.app.api.v1.endpoints.model,models_leakage_check,models_leakage_check,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:380,compatibility alias of /api/model/leakage-check -GET,/api/models/metrics,backend.app.api.v1.endpoints.model,models_metrics,models_metrics,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:363,none -GET,/api/models/retrain-suggestion,backend.app.api.v1.endpoints.model,models_retrain_suggestion,models_retrain_suggestion,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:365,none +POST,/api/models/center/rollback,backend.app.api.v1.endpoints.model,models_center_rollback,models_center_rollback,,admin,auth:get_current_user|permission:model:manage,authenticated+permission:model:manage,model:manage,admin,no,yes,frontend/src/api.ts:377,none +POST,/api/models/center/training/start,backend.app.api.v1.endpoints.model,models_center_training_start,models_center_training_start,,admin,auth:get_current_user|permission:model:manage,authenticated+permission:model:manage,model:manage,admin,no,yes,frontend/src/api.ts:375,none +GET,/api/models/center/versions/{version},backend.app.api.v1.endpoints.model,models_center_version_detail,models_center_version_detail,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:374,none +GET,/api/models/errors,backend.app.api.v1.endpoints.model,models_errors,models_errors,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:365,none +GET,/api/models/feature-schema,backend.app.api.v1.endpoints.model,models_feature_schema,models_feature_schema,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:380,compatibility alias of /api/model/feature-schema +GET,/api/models/leakage-check,backend.app.api.v1.endpoints.model,models_leakage_check,models_leakage_check,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:381,compatibility alias of /api/model/leakage-check +GET,/api/models/metrics,backend.app.api.v1.endpoints.model,models_metrics,models_metrics,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:364,none +GET,/api/models/retrain-suggestion,backend.app.api.v1.endpoints.model,models_retrain_suggestion,models_retrain_suggestion,,sensitive,auth:get_current_user|permission:model:read,authenticated+permission:model:read,model:read,admin|analyst|developer,no,no,frontend/src/api.ts:366,none GET,/api/prediction/detail,backend.app.api.v1.endpoints.forecast,stage1_prediction_detail,stage1_prediction_detail,,business,none,authenticated+permission:forecast:read,forecast:read,admin|analyst|viewer|reviewer|developer,no,no,none,none GET,/api/prediction/latest,backend.app.api.v1.endpoints.forecast,stage1_prediction_latest,stage1_prediction_latest,,business,none,authenticated+permission:forecast:read,forecast:read,admin|analyst|viewer|reviewer|developer,no,no,none,none GET,/api/renewable/forecast,backend.app.api.v1.endpoints.forecast,stage1_renewable_forecast,stage1_renewable_forecast,,business,none,authenticated+permission:forecast:read,forecast:read,admin|analyst|viewer|reviewer|developer,no,no,none,none GET,/api/reports,backend.app.api.v1.endpoints.report,report_list,report_list,,business,auth:get_current_user|permission:report:read,authenticated+permission:report:read,report:read,admin|analyst|viewer|reviewer|developer,no,no,none,none -POST,/api/reports/generate,backend.app.api.v1.endpoints.report,report_generate,report_generate,,business,auth:get_current_user|permission:report:generate,authenticated+permission:report:generate,report:generate,admin|analyst,no,yes,frontend/src/api.ts:353,none -GET,/api/reports/latest,backend.app.api.v1.endpoints.report,report_latest,report_latest,,business,none,authenticated+permission:report:read,report:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:342,none -GET,/api/reports/summary,backend.app.api.v1.endpoints.report,report_summary,report_summary,,business,auth:get_current_user|permission:report:read,authenticated+permission:report:read,report:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:350,none -GET,/api/reports/{report_id},backend.app.api.v1.endpoints.report,report_detail,report_detail,,business,none,authenticated+permission:report:read,report:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:342;frontend/src/api.ts:350;frontend/src/api.ts:351;frontend/src/api.ts:353,none -POST,/api/reports/{report_id}/approve,backend.app.api.v1.endpoints.report,report_approve,report_approve,,sensitive,auth:get_current_user|permission:report:review,authenticated+permission:report:review,report:review,admin|reviewer,no,yes,frontend/src/api.ts:357,none -GET,/api/reports/{report_id}/download,backend.app.api.v1.endpoints.report,report_download,report_download,None,sensitive,none,authenticated+permission:report:download,report:download,admin|analyst|reviewer|developer,no,no,frontend/src/api.ts:352,download/export response -POST,/api/reports/{report_id}/publish,backend.app.api.v1.endpoints.report,report_publish,report_publish,,sensitive,auth:get_current_user|permission:report:review,authenticated+permission:report:review,report:review,admin|reviewer,no,yes,frontend/src/api.ts:361,none -POST,/api/reports/{report_id}/regenerate,backend.app.api.v1.endpoints.report,report_regenerate,report_regenerate,,business,auth:get_current_user|permission:report:generate,authenticated+permission:report:generate,report:generate,admin|analyst,no,yes,frontend/src/api.ts:355,none -POST,/api/reports/{report_id}/reject,backend.app.api.v1.endpoints.report,report_reject,report_reject,,sensitive,auth:get_current_user|permission:report:review,authenticated+permission:report:review,report:review,admin|reviewer,no,yes,frontend/src/api.ts:359,none -GET,/api/reports/{report_id}/reviews,backend.app.api.v1.endpoints.report,report_reviews,report_reviews,,sensitive,none,authenticated+permission:report:review,report:review,admin|reviewer,no,no,frontend/src/api.ts:362,none +POST,/api/reports/generate,backend.app.api.v1.endpoints.report,report_generate,report_generate,,business,auth:get_current_user|permission:report:generate,authenticated+permission:report:generate,report:generate,admin|analyst,no,yes,frontend/src/api.ts:354,none +GET,/api/reports/latest,backend.app.api.v1.endpoints.report,report_latest,report_latest,,business,auth:get_current_user|permission:report:read,authenticated+permission:report:read,report:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:343,none +GET,/api/reports/summary,backend.app.api.v1.endpoints.report,report_summary,report_summary,,business,auth:get_current_user|permission:report:read,authenticated+permission:report:read,report:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:351,none +GET,/api/reports/{report_id},backend.app.api.v1.endpoints.report,report_detail,report_detail,,business,auth:get_current_user|permission:report:read,authenticated+permission:report:read,report:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:343;frontend/src/api.ts:351;frontend/src/api.ts:352;frontend/src/api.ts:354,none +POST,/api/reports/{report_id}/approve,backend.app.api.v1.endpoints.report,report_approve,report_approve,,sensitive,auth:get_current_user|permission:report:review,authenticated+permission:report:review,report:review,admin|reviewer,no,yes,frontend/src/api.ts:358,none +GET,/api/reports/{report_id}/download,backend.app.api.v1.endpoints.report,report_download,report_download,None,sensitive,auth:get_current_user|permission:report:download,authenticated+permission:report:download,report:download,admin|analyst|reviewer|developer,no,no,frontend/src/api.ts:353,download/export response +POST,/api/reports/{report_id}/publish,backend.app.api.v1.endpoints.report,report_publish,report_publish,,admin,auth:get_current_user|permission:report:publish,authenticated+permission:report:publish,report:publish,admin,no,yes,frontend/src/api.ts:362,none +POST,/api/reports/{report_id}/regenerate,backend.app.api.v1.endpoints.report,report_regenerate,report_regenerate,,business,auth:get_current_user|permission:report:generate,authenticated+permission:report:generate,report:generate,admin|analyst,no,yes,frontend/src/api.ts:356,none +POST,/api/reports/{report_id}/reject,backend.app.api.v1.endpoints.report,report_reject,report_reject,,sensitive,auth:get_current_user|permission:report:review,authenticated+permission:report:review,report:review,admin|reviewer,no,yes,frontend/src/api.ts:360,none +GET,/api/reports/{report_id}/reviews,backend.app.api.v1.endpoints.report,report_reviews,report_reviews,,sensitive,auth:get_current_user|permission:report:review,authenticated+permission:report:review,report:review,admin|reviewer,no,no,frontend/src/api.ts:363,none GET,/api/risk/level,backend.app.api.v1.endpoints.forecast,stage1_risk_level,stage1_risk_level,,business,none,authenticated+permission:forecast:read,forecast:read,admin|analyst|viewer|reviewer|developer,no,no,none,none GET,/api/risk/summary,backend.app.api.v1.endpoints.forecast,risk_summary,risk_summary,,business,none,authenticated+permission:forecast:read,forecast:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:218,none -GET,/api/scheduled-tasks,backend.app.api.v1.endpoints.task,scheduled_tasks,scheduled_tasks,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:519;frontend/src/api.ts:520,same-path methods=GET|POST -POST,/api/scheduled-tasks,backend.app.api.v1.endpoints.task,scheduled_task_create,scheduled_task_create,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:519;frontend/src/api.ts:520,same-path methods=GET|POST -DELETE,/api/scheduled-tasks/{task_name},backend.app.api.v1.endpoints.task,scheduled_task_delete,scheduled_task_delete,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:522,none -GET,/api/security/me,backend.app.api.v1.endpoints.system,security_me,security_me,,business,auth:get_current_user,authenticated+permission:auth:self,auth:self,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:410,none -GET,/api/security/permissions,backend.app.api.v1.endpoints.system,security_permissions,security_permissions,,sensitive,auth:get_current_user|permission:security:read,authenticated+permission:security:read,security:read,admin|developer,no,no,frontend/src/api.ts:411,none -GET,/api/settings/audit-logs,backend.app.api.v1.endpoints.system,settings_audit_logs,settings_audit_logs,,sensitive,auth:get_current_user|permission:audit:read,authenticated+permission:audit:read,audit:read,admin|developer,no,no,frontend/src/api.ts:463,none -GET,/api/settings/config,backend.app.api.v1.endpoints.system,settings_config,settings_config,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:417;frontend/src/api.ts:418,same-path methods=GET|POST -POST,/api/settings/config,backend.app.api.v1.endpoints.system,settings_config_save,settings_config_save,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:417;frontend/src/api.ts:418,same-path methods=GET|POST -GET,/api/settings/health,backend.app.api.v1.endpoints.system,settings_health,settings_health,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:419,none +GET,/api/scheduled-tasks,backend.app.api.v1.endpoints.task,scheduled_tasks,scheduled_tasks,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:520;frontend/src/api.ts:521,same-path methods=GET|POST +POST,/api/scheduled-tasks,backend.app.api.v1.endpoints.task,scheduled_task_create,scheduled_task_create,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:520;frontend/src/api.ts:521,same-path methods=GET|POST +DELETE,/api/scheduled-tasks/{task_name},backend.app.api.v1.endpoints.task,scheduled_task_delete,scheduled_task_delete,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:523,none +GET,/api/security/me,backend.app.api.v1.endpoints.system,security_me,security_me,,business,auth:get_current_user,authenticated+permission:auth:self,auth:self,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:411,none +GET,/api/security/permissions,backend.app.api.v1.endpoints.system,security_permissions,security_permissions,,sensitive,auth:get_current_user|permission:security:read,authenticated+permission:security:read,security:read,admin|developer,no,no,frontend/src/api.ts:412,none +GET,/api/settings/audit-logs,backend.app.api.v1.endpoints.system,settings_audit_logs,settings_audit_logs,,sensitive,auth:get_current_user|permission:audit:read,authenticated+permission:audit:read,audit:read,admin|developer,no,no,frontend/src/api.ts:464,none +GET,/api/settings/config,backend.app.api.v1.endpoints.system,settings_config,settings_config,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:418;frontend/src/api.ts:419,same-path methods=GET|POST +POST,/api/settings/config,backend.app.api.v1.endpoints.system,settings_config_save,settings_config_save,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:418;frontend/src/api.ts:419,same-path methods=GET|POST +GET,/api/settings/health,backend.app.api.v1.endpoints.system,settings_health,settings_health,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:420,none GET,/api/settings/interfaces,backend.app.api.v1.endpoints.system,settings_interfaces,settings_interfaces,,sensitive,auth:get_current_user|permission:settings:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,none,none GET,/api/settings/interfaces/configs,backend.app.api.v1.endpoints.system,settings_interfaces_configs,settings_interfaces_configs,,sensitive,auth:get_current_user|permission:settings:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,none,none -GET,/api/settings/interfaces/overview,backend.app.api.v1.endpoints.system,settings_interfaces_overview,settings_interfaces_overview,,sensitive,auth:get_current_user|permission:settings:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:465,none -POST,/api/settings/interfaces/test-all,backend.app.api.v1.endpoints.system,settings_interfaces_test_all,settings_interfaces_test_all,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:477,none +GET,/api/settings/interfaces/overview,backend.app.api.v1.endpoints.system,settings_interfaces_overview,settings_interfaces_overview,,sensitive,auth:get_current_user|permission:settings:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:466,none +POST,/api/settings/interfaces/test-all,backend.app.api.v1.endpoints.system,settings_interfaces_test_all,settings_interfaces_test_all,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:478,none GET,/api/settings/interfaces/test-logs,backend.app.api.v1.endpoints.system,settings_interfaces_test_logs,settings_interfaces_test_logs,,sensitive,auth:get_current_user|permission:settings:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,none,none -PUT,/api/settings/interfaces/{interface_id},backend.app.api.v1.endpoints.system,settings_interface_update,settings_interface_update,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:465;frontend/src/api.ts:474;frontend/src/api.ts:477,none -POST,/api/settings/interfaces/{interface_id}/test,backend.app.api.v1.endpoints.system,settings_interface_test,settings_interface_test,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:476,none -GET,/api/settings/roles/permissions,backend.app.api.v1.endpoints.system,settings_roles_permissions,settings_roles_permissions,,sensitive,auth:get_current_user|permission:user:read,authenticated+permission:user:read,user:read,admin|developer,no,no,frontend/src/api.ts:454;frontend/src/api.ts:456,same-path methods=GET|PUT -PUT,/api/settings/roles/permissions,backend.app.api.v1.endpoints.system,settings_roles_permissions_update,settings_roles_permissions_update,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:454;frontend/src/api.ts:456,same-path methods=GET|PUT -GET,/api/settings/runtime-config,backend.app.api.v1.endpoints.system,settings_runtime_config,settings_runtime_config,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:423;frontend/src/api.ts:424,same-path methods=GET|PUT -PUT,/api/settings/runtime-config,backend.app.api.v1.endpoints.system,settings_runtime_config_update,settings_runtime_config_update,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:423;frontend/src/api.ts:424,same-path methods=GET|PUT -GET,/api/settings/security-policy,backend.app.api.v1.endpoints.system,settings_security_policy,settings_security_policy,,sensitive,auth:get_current_user|permission:user:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:457;frontend/src/api.ts:459,same-path methods=GET|PUT -PUT,/api/settings/security-policy,backend.app.api.v1.endpoints.system,settings_security_policy_update,settings_security_policy_update,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:457;frontend/src/api.ts:459,same-path methods=GET|PUT -GET,/api/settings/status/check-records,backend.app.api.v1.endpoints.system,settings_check_records,settings_check_records,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:425,none -GET,/api/settings/status/health-details,backend.app.api.v1.endpoints.system,settings_health_details,settings_health_details,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:422,none -GET,/api/settings/status/overview,backend.app.api.v1.endpoints.system,settings_status_overview,settings_status_overview,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:420,none -GET,/api/settings/status/summary,backend.app.api.v1.endpoints.system,settings_status_summary,settings_status_summary,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:421,none -GET,/api/settings/status/task-queue-snapshot,backend.app.api.v1.endpoints.system,settings_task_queue_snapshot,settings_task_queue_snapshot,,sensitive,auth:get_current_user|permission:task:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:426,none -GET,/api/settings/users,backend.app.api.v1.endpoints.system,settings_users,settings_users,,sensitive,auth:get_current_user|permission:user:read,authenticated+permission:user:read,user:read,admin|developer,no,no,frontend/src/api.ts:435,same-path methods=GET|POST -POST,/api/settings/users,backend.app.api.v1.endpoints.system,settings_user_create,settings_user_create,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:435,same-path methods=GET|POST -GET,/api/settings/users/overview,backend.app.api.v1.endpoints.system,settings_users_overview,settings_users_overview,,sensitive,auth:get_current_user|permission:user:read,authenticated+permission:user:read,user:read,admin|developer,no,no,frontend/src/api.ts:427,none -DELETE,/api/settings/users/{user_id},backend.app.api.v1.endpoints.system,settings_user_delete,settings_user_delete,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:427;frontend/src/api.ts:437;frontend/src/api.ts:453,same-path methods=DELETE|PUT -PUT,/api/settings/users/{user_id},backend.app.api.v1.endpoints.system,settings_user_update,settings_user_update,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:427;frontend/src/api.ts:437;frontend/src/api.ts:453,same-path methods=DELETE|PUT -POST,/api/settings/users/{user_id}/assign-role,backend.app.api.v1.endpoints.system,settings_user_assign_role,settings_user_assign_role,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:448,none -POST,/api/settings/users/{user_id}/disable,backend.app.api.v1.endpoints.system,settings_user_disable,settings_user_disable,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:439,none -POST,/api/settings/users/{user_id}/enable,backend.app.api.v1.endpoints.system,settings_user_enable,settings_user_enable,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:441,none -POST,/api/settings/users/{user_id}/reset-password,backend.app.api.v1.endpoints.system,settings_user_reset_password,settings_user_reset_password,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:443,none +PUT,/api/settings/interfaces/{interface_id},backend.app.api.v1.endpoints.system,settings_interface_update,settings_interface_update,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:466;frontend/src/api.ts:475;frontend/src/api.ts:478,none +POST,/api/settings/interfaces/{interface_id}/test,backend.app.api.v1.endpoints.system,settings_interface_test,settings_interface_test,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:477,none +GET,/api/settings/roles/permissions,backend.app.api.v1.endpoints.system,settings_roles_permissions,settings_roles_permissions,,sensitive,auth:get_current_user|permission:user:read,authenticated+permission:user:read,user:read,admin|developer,no,no,frontend/src/api.ts:455;frontend/src/api.ts:457,same-path methods=GET|PUT +PUT,/api/settings/roles/permissions,backend.app.api.v1.endpoints.system,settings_roles_permissions_update,settings_roles_permissions_update,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:455;frontend/src/api.ts:457,same-path methods=GET|PUT +GET,/api/settings/runtime-config,backend.app.api.v1.endpoints.system,settings_runtime_config,settings_runtime_config,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:424;frontend/src/api.ts:425,same-path methods=GET|PUT +PUT,/api/settings/runtime-config,backend.app.api.v1.endpoints.system,settings_runtime_config_update,settings_runtime_config_update,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:424;frontend/src/api.ts:425,same-path methods=GET|PUT +GET,/api/settings/security-policy,backend.app.api.v1.endpoints.system,settings_security_policy,settings_security_policy,,sensitive,auth:get_current_user|permission:user:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:458;frontend/src/api.ts:460,same-path methods=GET|PUT +PUT,/api/settings/security-policy,backend.app.api.v1.endpoints.system,settings_security_policy_update,settings_security_policy_update,,admin,auth:get_current_user|permission:settings:write,authenticated+permission:settings:write,settings:write,admin,no,yes,frontend/src/api.ts:458;frontend/src/api.ts:460,same-path methods=GET|PUT +GET,/api/settings/status/check-records,backend.app.api.v1.endpoints.system,settings_check_records,settings_check_records,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:426,none +GET,/api/settings/status/health-details,backend.app.api.v1.endpoints.system,settings_health_details,settings_health_details,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:423,none +GET,/api/settings/status/overview,backend.app.api.v1.endpoints.system,settings_status_overview,settings_status_overview,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:421,none +GET,/api/settings/status/summary,backend.app.api.v1.endpoints.system,settings_status_summary,settings_status_summary,,sensitive,auth:get_current_user|permission:dashboard:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:422,none +GET,/api/settings/status/task-queue-snapshot,backend.app.api.v1.endpoints.system,settings_task_queue_snapshot,settings_task_queue_snapshot,,sensitive,auth:get_current_user|permission:task:read,authenticated+permission:settings:read,settings:read,admin|developer,no,no,frontend/src/api.ts:427,none +GET,/api/settings/users,backend.app.api.v1.endpoints.system,settings_users,settings_users,,sensitive,auth:get_current_user|permission:user:read,authenticated+permission:user:read,user:read,admin|developer,no,no,frontend/src/api.ts:436,same-path methods=GET|POST +POST,/api/settings/users,backend.app.api.v1.endpoints.system,settings_user_create,settings_user_create,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:436,same-path methods=GET|POST +GET,/api/settings/users/overview,backend.app.api.v1.endpoints.system,settings_users_overview,settings_users_overview,,sensitive,auth:get_current_user|permission:user:read,authenticated+permission:user:read,user:read,admin|developer,no,no,frontend/src/api.ts:428,none +DELETE,/api/settings/users/{user_id},backend.app.api.v1.endpoints.system,settings_user_delete,settings_user_delete,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:428;frontend/src/api.ts:438;frontend/src/api.ts:454,same-path methods=DELETE|PUT +PUT,/api/settings/users/{user_id},backend.app.api.v1.endpoints.system,settings_user_update,settings_user_update,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:428;frontend/src/api.ts:438;frontend/src/api.ts:454,same-path methods=DELETE|PUT +POST,/api/settings/users/{user_id}/assign-role,backend.app.api.v1.endpoints.system,settings_user_assign_role,settings_user_assign_role,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:449,none +POST,/api/settings/users/{user_id}/disable,backend.app.api.v1.endpoints.system,settings_user_disable,settings_user_disable,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:440,none +POST,/api/settings/users/{user_id}/enable,backend.app.api.v1.endpoints.system,settings_user_enable,settings_user_enable,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:442,none +POST,/api/settings/users/{user_id}/reset-password,backend.app.api.v1.endpoints.system,settings_user_reset_password,settings_user_reset_password,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:444,none GET,/api/source/context,backend.app.api.v1.endpoints.forecast,source_context,source_context,,business,none,authenticated+permission:forecast:read,forecast:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:276,none GET,/api/strategies,backend.app.api.v1.endpoints.strategy,governed_strategy_list,governed_strategy_list,,business,auth:get_current_user|permission:strategy:read,authenticated+permission:strategy:read,strategy:read,admin|analyst|viewer|reviewer|developer,no,no,none,none POST,/api/strategies/generate,backend.app.api.v1.endpoints.strategy,governed_strategy_generate,governed_strategy_generate,,business,auth:get_current_user|permission:strategy:generate,authenticated+permission:strategy:generate,strategy:generate,admin|analyst,no,yes,frontend/src/api.ts:295,none @@ -179,22 +179,22 @@ POST,/api/strategy/reviews,backend.app.api.v1.endpoints.strategy,strategy_review GET,/api/strategy/runtime-facts,backend.app.api.v1.endpoints.strategy,strategy_runtime_facts,strategy_runtime_facts,,business,auth:get_current_user|permission:strategy:read,authenticated+permission:strategy:read,strategy:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:283,none GET,/api/strategy/today,backend.app.api.v1.endpoints.strategy,strategy_today,strategy_today,,business,none,authenticated+permission:strategy:read,strategy:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:282,none GET,/api/strategy/{run_id},backend.app.api.v1.endpoints.strategy,strategy_by_run,strategy_by_run,,business,none,authenticated+permission:strategy:read,strategy:read,admin|analyst|viewer|reviewer|developer,no,no,frontend/src/api.ts:281;frontend/src/api.ts:282;frontend/src/api.ts:283;frontend/src/api.ts:284;frontend/src/api.ts:285;frontend/src/api.ts:286;frontend/src/api.ts:287,none -GET,/api/task/recent,backend.app.api.v1.endpoints.task,task_recent,task_recent,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:514,none -GET,/api/tasks,backend.app.api.v1.endpoints.task,task_list,task_list,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:512;frontend/src/api.ts:513,same-path methods=GET|POST -POST,/api/tasks,backend.app.api.v1.endpoints.task,task_create,task_create,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:512;frontend/src/api.ts:513,same-path methods=GET|POST -GET,/api/tasks/health,backend.app.api.v1.endpoints.task,task_health,task_health,,sensitive,none,authenticated+permission:task:diagnostics,task:diagnostics,admin|developer,no,no,frontend/src/api.ts:515,none +GET,/api/task/recent,backend.app.api.v1.endpoints.task,task_recent,task_recent,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:515,none +GET,/api/tasks,backend.app.api.v1.endpoints.task,task_list,task_list,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:513;frontend/src/api.ts:514,same-path methods=GET|POST +POST,/api/tasks,backend.app.api.v1.endpoints.task,task_create,task_create,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:513;frontend/src/api.ts:514,same-path methods=GET|POST +GET,/api/tasks/health,backend.app.api.v1.endpoints.task,task_health,task_health,,sensitive,none,authenticated+permission:task:diagnostics,task:diagnostics,admin|developer,no,no,frontend/src/api.ts:516,none GET,/api/tasks/logs/recent,backend.app.api.v1.endpoints.task,task_logs_recent,task_logs_recent,,sensitive,none,authenticated+permission:task:diagnostics,task:diagnostics,admin|developer,no,no,none,none -GET,/api/tasks/overview,backend.app.api.v1.endpoints.task,task_center_overview,task_center_overview,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:492,none -GET,/api/tasks/queues/overview,backend.app.api.v1.endpoints.task,task_queues_overview,task_queues_overview,,sensitive,none,authenticated+permission:task:diagnostics,task:diagnostics,admin|developer,no,no,frontend/src/api.ts:508,none -GET,/api/tasks/retry/recent,backend.app.api.v1.endpoints.task,task_retry_recent,task_retry_recent,,sensitive,none,authenticated+permission:task:diagnostics,task:diagnostics,admin|developer,no,no,frontend/src/api.ts:507,none -POST,/api/tasks/run,backend.app.api.v1.endpoints.task,task_run,task_run,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:511,none +GET,/api/tasks/overview,backend.app.api.v1.endpoints.task,task_center_overview,task_center_overview,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:493,none +GET,/api/tasks/queues/overview,backend.app.api.v1.endpoints.task,task_queues_overview,task_queues_overview,,sensitive,none,authenticated+permission:task:diagnostics,task:diagnostics,admin|developer,no,no,frontend/src/api.ts:509,none +GET,/api/tasks/retry/recent,backend.app.api.v1.endpoints.task,task_retry_recent,task_retry_recent,,sensitive,none,authenticated+permission:task:diagnostics,task:diagnostics,admin|developer,no,no,frontend/src/api.ts:508,none +POST,/api/tasks/run,backend.app.api.v1.endpoints.task,task_run,task_run,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:512,none GET,/api/tasks/runs,backend.app.api.v1.endpoints.task,task_runs,task_runs,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,none,none -POST,/api/tasks/start,backend.app.api.v1.endpoints.task,task_start,task_start,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:510,none -GET,/api/tasks/trend,backend.app.api.v1.endpoints.task,task_trend_api,task_trend_api,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:509,none -GET,/api/tasks/{task_id},backend.app.api.v1.endpoints.task,task_detail,task_detail,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:492;frontend/src/api.ts:509;frontend/src/api.ts:510;frontend/src/api.ts:511;frontend/src/api.ts:515;frontend/src/api.ts:516,none -POST,/api/tasks/{task_id}/cancel,backend.app.api.v1.endpoints.task,task_cancel,task_cancel,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:517,none -GET,/api/tasks/{task_id}/logs,backend.app.api.v1.endpoints.task,task_logs,task_logs,,sensitive,none,authenticated+permission:task:diagnostics,task:diagnostics,admin|developer,no,no,frontend/src/api.ts:524,none -POST,/api/tasks/{task_id}/retry,backend.app.api.v1.endpoints.task,task_retry,task_retry,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:518,none +POST,/api/tasks/start,backend.app.api.v1.endpoints.task,task_start,task_start,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:511,none +GET,/api/tasks/trend,backend.app.api.v1.endpoints.task,task_trend_api,task_trend_api,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:510,none +GET,/api/tasks/{task_id},backend.app.api.v1.endpoints.task,task_detail,task_detail,,business,none,authenticated+permission:task:read,task:read,admin|analyst|developer,no,no,frontend/src/api.ts:493;frontend/src/api.ts:510;frontend/src/api.ts:511;frontend/src/api.ts:512;frontend/src/api.ts:516;frontend/src/api.ts:517,none +POST,/api/tasks/{task_id}/cancel,backend.app.api.v1.endpoints.task,task_cancel,task_cancel,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:518,none +GET,/api/tasks/{task_id}/logs,backend.app.api.v1.endpoints.task,task_logs,task_logs,,sensitive,none,authenticated+permission:task:diagnostics,task:diagnostics,admin|developer,no,no,frontend/src/api.ts:525,none +POST,/api/tasks/{task_id}/retry,backend.app.api.v1.endpoints.task,task_retry,task_retry,,admin,auth:get_current_user|permission:task:run,authenticated+permission:task:manage,task:manage,admin,no,yes,frontend/src/api.ts:519,none GET,/api/users,backend.app.api.v1.endpoints.users,user_list,user_list,,sensitive,auth:get_current_user|permission:user:read,authenticated+permission:user:read,user:read,admin|developer,no,no,frontend/src/api.ts:196,same-path methods=GET|POST POST,/api/users,backend.app.api.v1.endpoints.users,user_create,user_create,,admin,auth:get_current_user|permission:user:write,authenticated+permission:user:write,user:write,admin,no,yes,frontend/src/api.ts:196,same-path methods=GET|POST GET,/api/users/roles,backend.app.api.v1.endpoints.users,user_roles,user_roles,,sensitive,auth:get_current_user|permission:user:read,authenticated+permission:user:read,user:read,admin|developer,no,no,frontend/src/api.ts:195,none diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 8af735f..ac88fb9 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -125,9 +125,15 @@ def permissions_for_role(role: str) -> list[str]: except Exception: configured = None if configured is not None: - values = sorted({str(value) for value in (configured.get("permissions") or []) if str(value)}) - if normalized != "admin" or "*" in values: - return values + values = { + str(value) + for value in (configured.get("permissions") or []) + if str(value) + } + ceiling = ROLE_PERMISSIONS.get(normalized, set()) + if normalized == "admin": + return ["*"] if "*" in values else [] + return sorted(values & ceiling) return sorted(ROLE_PERMISSIONS.get(normalized, set())) diff --git a/backend/app/platform_services.py b/backend/app/platform_services.py index 2a9256a..889f1cd 100644 --- a/backend/app/platform_services.py +++ b/backend/app/platform_services.py @@ -598,95 +598,31 @@ def get_chat_session(session_id: str) -> dict[str, Any]: return {"session_id": session_id, "messages": records(messages)} -def _report_review_columns() -> set[str]: - engine = database_engine() - if engine is None: - return set() - try: - with engine.connect() as conn: - rows = conn.execute( - text( - """ - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'report_reviews' - """ - ) - ).mappings().all() - return {str(row.get("column_name")) for row in rows} - except Exception: - return set() - - -def save_report_review(report_id: str, status: str, reviewer: str, comment: str) -> dict[str, Any]: - report = report_status(report_id) - columns = _report_review_columns() - now = datetime.now().isoformat(sep=" ", timespec="seconds") - row = { - "report_id": report_id, - "reviewer": reviewer, - "created_at": now, - } - if "run_id" in columns: - row["run_id"] = report.get("run_id") or report_id - if "status" in columns: - row["status"] = status - if "action" in columns: - row["action"] = status - if "review_comment" in columns: - row["review_comment"] = comment - if "comment" in columns: - row["comment"] = comment - if "version" in columns: - row["version"] = 1 - if "updated_at" in columns: - row["updated_at"] = now - if "metadata_json" in columns: - row["metadata_json"] = {"source": "ui_report_review"} - _insert_rows("report_reviews", [row]) - local_path = project_paths().current_dir / "web_report_reviews.json" - current = [] - if local_path.exists(): - try: - current = json.loads(local_path.read_text(encoding="utf-8")) - except Exception: - current = [] - current.append(row) - _write_local_json("web_report_reviews.json", current) - return row +def save_report_review( + report_id: str, + status: str, + reviewer: str, + comment: str, + *, + actor, +) -> dict[str, Any]: + from .services.report_governance_service import transition_report + + action = { + "approved": "approve", + "rejected": "reject", + "published": "publish", + }.get(str(status or "").strip().lower(), str(status or "").strip().lower()) + return transition_report( + report_id, + action=action, + actor=actor, + comment=comment, + requested_reviewer=reviewer, + ) def list_report_reviews(report_id: str) -> list[dict[str, Any]]: - columns = _report_review_columns() - if columns: - run_id_expr = "run_id" if "run_id" in columns else "NULL AS run_id" - status_expr = "status" if "status" in columns else ("action AS status" if "action" in columns else "NULL AS status") - comment_expr = "review_comment" if "review_comment" in columns else ("comment AS review_comment" if "comment" in columns else "NULL AS review_comment") - version_expr = "version" if "version" in columns else "NULL AS version" - updated_expr = "updated_at" if "updated_at" in columns else "created_at AS updated_at" - order_expr = "updated_at" if "updated_at" in columns else "created_at" - df = query_dataframe( - f""" - SELECT report_id, {run_id_expr}, {status_expr}, reviewer, - {comment_expr}, {version_expr}, created_at, {updated_expr} - FROM report_reviews - WHERE report_id = :report_id - ORDER BY {order_expr} DESC - """, - {"report_id": report_id}, - ) - else: - df = query_dataframe( - "SELECT report_id, run_id, status, reviewer, review_comment, version, created_at, updated_at FROM report_reviews WHERE report_id = :report_id ORDER BY updated_at DESC", - {"report_id": report_id}, - ) - if not df.empty: - return records(df) - local = project_paths().current_dir / "web_report_reviews.json" - if local.exists(): - try: - rows = json.loads(local.read_text(encoding="utf-8")) - return [row for row in rows if row.get("report_id") == report_id] - except Exception: - return [] - return [] + from .services.report_governance_service import list_report_review_events + + return list_report_review_events(report_id) diff --git a/backend/app/repositories/model_repository.py b/backend/app/repositories/model_repository.py index 2f4ccad..4649007 100644 --- a/backend/app/repositories/model_repository.py +++ b/backend/app/repositories/model_repository.py @@ -1008,45 +1008,33 @@ def record_model_event( def start_model_training(user: CurrentUser | None = None, payload: dict[str, Any] | None = None) -> dict[str, Any]: - engine = postgres_engine() - task_id = f"train_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid4().hex[:4]}" - metadata = {"trigger": "model_center", **(payload or {})} - if engine is not None: - with engine.begin() as conn: - conn.execute( - text( - """ - INSERT INTO task_runs ( - task_id, run_id, task_name, task_kind, status, payload_json, - created_at, updated_at - ) - VALUES ( - :task_id, :task_id, '模型中心手动训练', 'retrain_model', 'pending', - CAST(:payload AS jsonb), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP - ) - ON CONFLICT (task_id) DO NOTHING - """ - ), - {"task_id": task_id, "payload": dumps_json(metadata)}, - ) - try: - from .task_repository import append_task_log + from backend.app.workers.dispatcher import enqueue_task - append_task_log( - task_id, - level="info", - step="submit", - message="模型训练任务已提交,等待训练执行器调度。", - status="pending", - task_name="模型中心手动训练", - task_kind="retrain_model", - metadata=metadata, - sequence_no=1, - ) - except Exception: - pass - event = record_model_event(action="training.start", target_version="", user=user, reason="用户从模型中心启动训练", metadata=metadata) - return {"task_id": task_id, "status": "pending", "event": event} + requested = dict(payload or {}) + rolling_backtest = bool(requested.get("rolling_backtest", False)) + metadata = { + "trigger": "model_center", + **requested, + "rolling_backtest": rolling_backtest, + "evaluation_profile": "full_rolling_backtest" if rolling_backtest else "train_validation_test", + "created_by": user.username if user else "", + } + result = enqueue_task("retrain_model", metadata) + event = record_model_event( + action="training.start", + target_version="", + user=user, + reason=str(metadata.get("reason") or "模型中心手动启动训练"), + status=str(result.get("status") or "queued"), + metadata={ + **metadata, + "task_id": result.get("task_id"), + "run_id": result.get("run_id"), + "celery_task_id": result.get("celery_task_id"), + "queue_name": result.get("queue_name"), + }, + ) + return {**result, "event": event, "event_recorded": bool(event.get("available"))} def activate_model_version( @@ -1060,27 +1048,37 @@ def activate_model_version( engine = postgres_engine() if engine is None: return {"success": False, "message": "PostgreSQL 不可用"} + safe_version = str(version or "").strip() + safe_reason = str(reason or "").strip() + if not safe_version: + return {"success": False, "message": "模型版本不能为空"} + if not safe_reason: + return {"success": False, "message": "模型激活原因不能为空"} service = ModelFactService(engine) - current = service.get_active_model(domain, target_name) + event_id = f"mge_{uuid4().hex[:16]}" try: - active = service.activate_model(version, domain, target_name) + active = service.activate_model( + safe_version, + domain, + target_name, + governance_event={ + "event_id": event_id, + "action": "model.activate", + "operator": user.username if user else "", + "reason": safe_reason, + "metadata": {"domain": domain, "target_name": target_name, "source_type": "registry"}, + }, + ) except ModelFactError as exc: return {"success": False, "message": str(exc)} - event = record_model_event( - action="model.activate", - target_version=version, - source_version=str(current.get("model_version") or ""), - user=user, - reason=reason, - metadata={"domain": domain, "target_name": target_name, "source_type": "registry"}, - ) return { "success": True, "active_version": active.get("model_version"), - "previous_active_version": current.get("model_version") or "", + "previous_active_version": active.get("previous_active_version") or "", "domain": domain, "target_name": target_name, - "event": event, + "idempotent": bool(active.get("idempotent")), + "event": {"available": True, "event_id": event_id}, } @@ -1095,25 +1093,35 @@ def rollback_model_version( engine = postgres_engine() if engine is None: return {"success": False, "message": "PostgreSQL 不可用"} + safe_version = str(version or "").strip() + safe_reason = str(reason or "").strip() + if not safe_version: + return {"success": False, "message": "模型版本不能为空"} + if not safe_reason: + return {"success": False, "message": "模型回滚原因不能为空"} service = ModelFactService(engine) - current = service.get_active_model(domain, target_name) + event_id = f"mge_{uuid4().hex[:16]}" try: - active = service.rollback_active_model(version, domain, target_name) + active = service.rollback_active_model( + safe_version, + domain, + target_name, + governance_event={ + "event_id": event_id, + "action": "model.rollback", + "operator": user.username if user else "", + "reason": safe_reason, + "metadata": {"domain": domain, "target_name": target_name, "source_type": "registry"}, + }, + ) except ModelFactError as exc: return {"success": False, "message": str(exc)} - event = record_model_event( - action="model.rollback", - target_version=version, - source_version=str(current.get("model_version") or ""), - user=user, - reason=reason or "模型中心回滚操作", - metadata={"domain": domain, "target_name": target_name, "source_type": "registry"}, - ) return { "success": True, "active_version": active.get("model_version"), - "previous_active_version": current.get("model_version") or "", + "previous_active_version": active.get("previous_active_version") or "", "domain": domain, "target_name": target_name, - "event": event, + "idempotent": bool(active.get("idempotent")), + "event": {"available": True, "event_id": event_id}, } diff --git a/backend/app/repositories/report_repository.py b/backend/app/repositories/report_repository.py index 466dbd4..9fd00c5 100644 --- a/backend/app/repositories/report_repository.py +++ b/backend/app/repositories/report_repository.py @@ -127,6 +127,8 @@ def report_status_from_postgres(report_id: str = "latest") -> dict[str, Any] | N "summary": content if isinstance(content, dict) else {}, "metadata": metadata if isinstance(metadata, dict) else {}, "status": data.get("status"), + "report_type": data.get("report_type"), + "title": data.get("title"), "source": "postgresql.report_runs", } @@ -191,7 +193,9 @@ def report_summary_from_postgres() -> dict[str, Any] | None: today = 0 pending = 0 published = 0 + approved = 0 rejected = 0 + archived = 0 for item in items: generated = str(item.get("generated_at") or "")[:10] status = str(item.get("status") or "") @@ -199,15 +203,22 @@ def report_summary_from_postgres() -> dict[str, Any] | None: today += 1 if status in {"draft", "ready", "pending", "reviewing"}: pending += 1 - if status in {"published", "approved"}: + if status == "approved": + approved += 1 + if status == "published": published += 1 if status == "rejected": rejected += 1 + if status == "archived": + archived += 1 return { "today_generated": today, "pending_review": pending, "published": published, + "approved": approved, + "pending_publish": approved, "rejected": rejected, + "archived": archived, "total": payload.get("total") or len(items), "source": payload.get("source"), } diff --git a/backend/app/runtime_identity.py b/backend/app/runtime_identity.py new file mode 100644 index 0000000..ec8406a --- /dev/null +++ b/backend/app/runtime_identity.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import subprocess +from functools import lru_cache +from pathlib import Path +from typing import Any + +from sqlalchemy import text + +from .config import APP_VERSION, PROJECT_ROOT + + +def _git(*args: str) -> str: + try: + result = subprocess.run( + ["git", "-C", str(PROJECT_ROOT), *args], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return "unknown" + return result.stdout.strip() or "unknown" + + +def source_fingerprint(root: Path = PROJECT_ROOT) -> str: + normalized = os.path.normcase(str(root.resolve())) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16] + + +@lru_cache(maxsize=1) +def source_alembic_heads() -> tuple[str, ...]: + try: + from alembic.config import Config + from alembic.script import ScriptDirectory + + config = Config(str(PROJECT_ROOT / "alembic.ini")) + return tuple(sorted(ScriptDirectory.from_config(config).get_heads())) + except Exception: + return () + + +@lru_cache(maxsize=1) +def database_alembic_current() -> tuple[str, ...]: + attested = os.environ.get("APP_DATABASE_ALEMBIC_CURRENT", "").strip() + if attested: + try: + values = json.loads(attested) + except json.JSONDecodeError: + return () + if not isinstance(values, list) or not values: + return () + revisions = tuple(sorted(str(value) for value in values)) + if any(not re.fullmatch(r"[A-Za-z0-9_]+", value) for value in revisions): + return () + return revisions + if not os.environ.get("DATABASE_URL", "").strip(): + return () + try: + from .db.session import get_engine + + with get_engine().connect() as connection: + rows = connection.execute(text("SELECT version_num FROM alembic_version")).scalars() + return tuple(sorted(str(value) for value in rows)) + except Exception: + return () + + +@lru_cache(maxsize=1) +def source_identity() -> dict[str, Any]: + sha = os.environ.get("APP_GIT_SHA", "").strip() or _git("rev-parse", "HEAD") + branch = os.environ.get("APP_GIT_BRANCH", "").strip() or _git("branch", "--show-current") + dirty_override = os.environ.get("APP_WORKTREE_DIRTY", "").strip().lower() + if dirty_override: + dirty = dirty_override in {"1", "true", "yes", "on"} + else: + dirty = _git("status", "--porcelain", "--untracked-files=no") not in {"", "unknown"} + build_id = os.environ.get("APP_BUILD_ID", "").strip() or f"{APP_VERSION.lstrip('v')}+{sha[:12]}" + return { + "version": APP_VERSION, + "git_sha": sha, + "git_branch": branch, + "build_id": build_id, + "working_tree_dirty": dirty, + "source_fingerprint": source_fingerprint(), + "alembic_head": list(source_alembic_heads()), + } + + +def runtime_identity_payload() -> dict[str, Any]: + payload = dict(source_identity()) + current = list(database_alembic_current()) + payload["alembic_current"] = current + payload["alembic_match"] = bool(current) and current == payload["alembic_head"] + payload["database_revision_source"] = ( + os.environ.get("APP_DATABASE_REVISION_SOURCE", "").strip() + or ("runtime_database" if current else "unavailable") + ) + return payload diff --git a/backend/app/services/model_fact_service.py b/backend/app/services/model_fact_service.py index 6be4df6..97c8af1 100644 --- a/backend/app/services/model_fact_service.py +++ b/backend/app/services/model_fact_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import date, datetime from typing import Any @@ -204,11 +205,38 @@ def validate_candidate(self, model_version: str, domain: str, target_name: str) def reject_candidate(self, model_version: str, domain: str, target_name: str) -> dict[str, Any]: return self._transition(model_version, domain, target_name, {"candidate", "validating", "validated"}, "rejected") - def activate_model(self, model_version: str, domain: str, target_name: str) -> dict[str, Any]: - return self._activate(model_version, domain, target_name, allowed_states={"validated"}) + def activate_model( + self, + model_version: str, + domain: str, + target_name: str, + *, + governance_event: dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._activate( + model_version, + domain, + target_name, + allowed_states={"validated"}, + governance_event=governance_event, + ) - def rollback_active_model(self, model_version: str, domain: str, target_name: str) -> dict[str, Any]: - return self._activate(model_version, domain, target_name, allowed_states={"archived"}, require_validated_at=True) + def rollback_active_model( + self, + model_version: str, + domain: str, + target_name: str, + *, + governance_event: dict[str, Any] | None = None, + ) -> dict[str, Any]: + return self._activate( + model_version, + domain, + target_name, + allowed_states={"archived"}, + require_validated_at=True, + governance_event=governance_event, + ) def deactivate_model(self, model_version: str, domain: str, target_name: str) -> dict[str, Any]: return self._transition( @@ -275,53 +303,113 @@ def _activate( *, allowed_states: set[str], require_validated_at: bool = False, + governance_event: dict[str, Any] | None = None, ) -> dict[str, Any]: safe_domain, safe_target = _scope(domain, target_name) safe_version = str(model_version or "").strip() if self.engine is None: raise ModelFactError("模型事实数据库不可用") - with self.engine.begin() as conn: - target = self._locked_model(conn, safe_version, safe_domain, safe_target) - if not target: - raise ModelFactError("模型不存在或 domain/target 不匹配") - target_fact = _fact(target) - if target_fact["status"] == "active" and target_fact["is_active"]: - return target_fact - if target_fact["status"] not in allowed_states: - raise ModelFactError("只有 validated 模型或明确回滚的已验证 archived 模型可以激活") - if require_validated_at and not target_fact.get("validated_at"): - raise ModelFactError("未验证的 archived 模型不能回滚为 Active") - conn.execute( - text( - """ - UPDATE model_registry - SET status = 'archived', is_active = 0, deactivated_at = CURRENT_TIMESTAMP - WHERE domain = :domain AND target_name = :target_name - AND LOWER(status) = 'active' AND is_active = 1 - AND model_version <> :model_version - """ - ), - {"domain": safe_domain, "target_name": safe_target, "model_version": safe_version}, - ) - result = conn.execute( - text( - """ - UPDATE model_registry - SET status = 'active', is_active = 1, - activated_at = CURRENT_TIMESTAMP, deactivated_at = NULL - WHERE model_version = :model_version - AND domain = :domain AND target_name = :target_name - """ - ), - {"model_version": safe_version, "domain": safe_domain, "target_name": safe_target}, - ) - if result.rowcount != 1: - raise ModelFactError("模型激活失败,事务已回滚") - active = conn.execute( - text(f"SELECT {_MODEL_COLUMNS} FROM model_registry WHERE model_version = :model_version"), - {"model_version": safe_version}, - ).mappings().first() - return _fact(active) + event_payload = dict(governance_event or {}) + if event_payload and not str(event_payload.get("event_id") or "").strip(): + raise ModelFactError("治理事件 event_id 必须明确提供") + try: + with self.engine.begin() as conn: + target = self._locked_model(conn, safe_version, safe_domain, safe_target) + if not target: + raise ModelFactError("模型不存在或 domain/target 不匹配") + target_fact = _fact(target) + lock_suffix = " FOR UPDATE" if conn.dialect.name == "postgresql" else "" + current_active = conn.execute( + text( + "SELECT model_version FROM model_registry " + "WHERE domain = :domain AND target_name = :target_name " + "AND LOWER(status) = 'active' AND is_active = 1 " + "ORDER BY activated_at DESC, created_at DESC LIMIT 1" + + lock_suffix + ), + {"domain": safe_domain, "target_name": safe_target}, + ).mappings().first() + source_version = str((current_active or {}).get("model_version") or "") + already_active = bool( + target_fact["status"] == "active" and target_fact["is_active"] + ) + if not already_active: + if target_fact["status"] not in allowed_states: + raise ModelFactError("只有 validated 模型或明确回滚的已验证 archived 模型可以激活") + if require_validated_at and not target_fact.get("validated_at"): + raise ModelFactError("未验证的 archived 模型不能回滚为 Active") + conn.execute( + text( + """ + UPDATE model_registry + SET status = 'archived', is_active = 0, deactivated_at = CURRENT_TIMESTAMP + WHERE domain = :domain AND target_name = :target_name + AND LOWER(status) = 'active' AND is_active = 1 + AND model_version <> :model_version + """ + ), + {"domain": safe_domain, "target_name": safe_target, "model_version": safe_version}, + ) + result = conn.execute( + text( + """ + UPDATE model_registry + SET status = 'active', is_active = 1, + activated_at = CURRENT_TIMESTAMP, deactivated_at = NULL + WHERE model_version = :model_version + AND domain = :domain AND target_name = :target_name + """ + ), + {"model_version": safe_version, "domain": safe_domain, "target_name": safe_target}, + ) + if result.rowcount != 1: + raise ModelFactError("模型激活失败,事务已回滚") + if event_payload: + metadata_json = json.dumps( + event_payload.get("metadata") or {}, + ensure_ascii=False, + default=str, + ) + metadata_value = ( + "CAST(:metadata_json AS jsonb)" + if conn.dialect.name == "postgresql" + else ":metadata_json" + ) + conn.execute( + text( + f""" + INSERT INTO model_governance_events ( + event_id, action, target_version, source_version, operator, + reason, status, metadata_json, created_at + ) VALUES ( + :event_id, :action, :target_version, :source_version, :operator, + :reason, 'success', {metadata_value}, CURRENT_TIMESTAMP + ) + """ + ), + { + "event_id": str(event_payload["event_id"]), + "action": str(event_payload.get("action") or "model.activate"), + "target_version": safe_version, + "source_version": source_version, + "operator": str(event_payload.get("operator") or ""), + "reason": str(event_payload.get("reason") or ""), + "metadata_json": metadata_json, + }, + ) + active = conn.execute( + text(f"SELECT {_MODEL_COLUMNS} FROM model_registry WHERE model_version = :model_version"), + {"model_version": safe_version}, + ).mappings().first() + except ModelFactError: + raise + except Exception as exc: + raise ModelFactError("模型切换或治理事件写入失败,事务已回滚") from exc + result_fact = _fact(active) + result_fact["previous_active_version"] = source_version + result_fact["governance_event_id"] = str(event_payload.get("event_id") or "") + result_fact["idempotent"] = already_active + return result_fact def _locked_model(self, conn: Any, model_version: str, domain: str, target_name: str) -> Any: suffix = " FOR UPDATE" if conn.dialect.name == "postgresql" else "" diff --git a/backend/app/services/report_generation_service.py b/backend/app/services/report_generation_service.py index c58a083..380ef29 100644 --- a/backend/app/services/report_generation_service.py +++ b/backend/app/services/report_generation_service.py @@ -4,6 +4,7 @@ import json import math import re +from contextlib import contextmanager from datetime import date, datetime, timezone from pathlib import Path from typing import Any @@ -62,8 +63,14 @@ def _safe_report_type(value: str) -> str: def _safe_report_id(run_id: str, report_type: str) -> str: - safe_run = re.sub(r"[^A-Za-z0-9_-]", "_", run_id)[:80] - return f"p5c_{report_type}_{safe_run}" + prefix = f"p5c_{report_type}_" + safe_run = re.sub(r"[^A-Za-z0-9_-]", "_", run_id) + return prefix + safe_run[: 64 - len(prefix)] + + +def _versioned_report_id(root_report_id: str, version: int) -> str: + suffix = f"_v{int(version)}" + return f"{root_report_id[: 64 - len(suffix)]}{suffix}" def _public_hour(row: dict[str, Any]) -> dict[str, Any]: @@ -260,6 +267,26 @@ def _existing_report(engine: Engine, report_id: str) -> dict[str, Any] | None: return mapping_dict(row) if row else None +@contextmanager +def _report_generation_lock(engine: Engine, lock_key: str): + connection = engine.connect() + try: + connection.execute( + text("SELECT pg_advisory_lock(hashtext(:lock_key))"), + {"lock_key": f"report-generation:{lock_key}"}, + ) + yield + finally: + try: + connection.execute( + text("SELECT pg_advisory_unlock(hashtext(:lock_key))"), + {"lock_key": f"report-generation:{lock_key}"}, + ) + connection.commit() + finally: + connection.close() + + def generate_operational_report( engine: Engine, *, @@ -269,13 +296,68 @@ def generate_operational_report( report_date: str | None = None, output_root: Path | None = None, failure_inject: str | None = None, + source_report_id: str | None = None, + requested_by: str = "", ) -> dict[str, Any]: safe_type = _safe_report_type(report_type) run, rows, source_meta = resolve_forecast_source(run_id, engine=engine) if not rows: raise ReportGenerationError(str(source_meta.get("unavailable_reason") or "没有可用于报告的完整预测批次")) resolved_run_id = str(run.get("run_id") or "") - report_id = _safe_report_id(resolved_run_id, safe_type) + base_report_id = _safe_report_id(resolved_run_id, safe_type) + lock_key = str(source_report_id or base_report_id) + with _report_generation_lock(engine, lock_key): + return _generate_operational_report_locked( + engine, + run=run, + rows=rows, + source_meta=source_meta, + resolved_run_id=resolved_run_id, + safe_type=safe_type, + region=region, + report_date=report_date, + output_root=output_root, + failure_inject=failure_inject, + source_report_id=str(source_report_id or "").strip(), + requested_by=requested_by, + ) + + +def _generate_operational_report_locked( + engine: Engine, + *, + run: dict[str, Any], + rows: list[dict[str, Any]], + source_meta: dict[str, Any], + resolved_run_id: str, + safe_type: str, + region: str, + report_date: str | None, + output_root: Path | None, + failure_inject: str | None, + source_report_id: str, + requested_by: str, +) -> dict[str, Any]: + root_report_id = _safe_report_id(resolved_run_id, safe_type) + report_version = 1 + report_id = root_report_id + if source_report_id: + source = _existing_report(engine, source_report_id) + if source is None: + raise ReportGenerationError("重新生成的来源报告不存在") + source_metadata = loads_json(source.get("metadata_json"), default={}) + source_metadata = source_metadata if isinstance(source_metadata, dict) else {} + if str(source.get("status") or "").lower() != "rejected": + raise ReportGenerationError("只有已驳回报告可以重新生成新版本") + if str(source.get("run_id") or "") != resolved_run_id: + raise ReportGenerationError("来源报告与预测 run_id 不一致") + if str(source.get("report_type") or "") != safe_type: + raise ReportGenerationError("来源报告类型与重新生成请求不一致") + root_report_id = str( + source_metadata.get("root_report_id") or source_report_id + ) + report_version = int(source_metadata.get("report_version") or 1) + 1 + report_id = _versioned_report_id(root_report_id, report_version) existing = _existing_report(engine, report_id) if existing: metadata = loads_json(existing.get("metadata_json"), default={}) @@ -296,6 +378,9 @@ def generate_operational_report( "source_type": metadata.get("source_type") or source_meta.get("source_type"), "is_stale": bool(metadata.get("is_stale", source_meta.get("is_stale"))), "stale_reason": metadata.get("stale_reason") or source_meta.get("stale_reason"), + "report_version": int(metadata.get("report_version") or report_version), + "root_report_id": metadata.get("root_report_id") or root_report_id, + "source_report_id": metadata.get("source_report_id"), } report = build_operational_report( @@ -324,6 +409,11 @@ def generate_operational_report( raise ReportGenerationError("INJECTED_BEFORE_PERSIST") metadata = { "report_schema_version": REPORT_SCHEMA_VERSION, + "report_type": safe_type, + "report_version": report_version, + "root_report_id": root_report_id, + "source_report_id": source_report_id or None, + "created_by": str(requested_by or "").strip() or None, "region": report["region"], "source_type": source_meta.get("source_type"), "is_stale": bool(source_meta.get("is_stale")), @@ -382,4 +472,7 @@ def generate_operational_report( "source_type": source_meta.get("source_type"), "is_stale": bool(source_meta.get("is_stale")), "stale_reason": source_meta.get("stale_reason"), + "report_version": report_version, + "root_report_id": root_report_id, + "source_report_id": source_report_id or None, } diff --git a/backend/app/services/report_governance_service.py b/backend/app/services/report_governance_service.py new file mode 100644 index 0000000..239213e --- /dev/null +++ b/backend/app/services/report_governance_service.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from sqlalchemy import text +from sqlalchemy.engine import Engine + +from backend.app.core.security import CurrentUser +from backend.app.repositories.base import ( + dumps_json, + loads_json, + mapping_dict, + mapping_list, + postgres_engine, +) + + +ACTION_TARGET = { + "approve": "approved", + "reject": "rejected", + "publish": "published", +} +REVIEWABLE_STATES = {"draft", "ready", "pending", "reviewing"} + + +class ReportGovernanceError(RuntimeError): + def __init__(self, code: str, message: str, *, status_code: int) -> None: + super().__init__(message) + self.code = code + self.status_code = status_code + + +def validate_report_transition( + *, + current_status: str, + action: str, + actor: CurrentUser, + comment: str, + metadata: dict[str, Any] | None = None, + requested_reviewer: str = "", +) -> tuple[str, bool]: + normalized_action = str(action or "").strip().lower() + target = ACTION_TARGET.get(normalized_action) + if target is None: + raise ReportGovernanceError( + "report_action_invalid", "不支持的报告状态操作。", status_code=400 + ) + if not actor.username.strip(): + raise ReportGovernanceError( + "report_actor_required", "当前登录身份不可用。", status_code=401 + ) + claimed = str(requested_reviewer or "").strip() + if claimed and claimed != actor.username: + raise ReportGovernanceError( + "report_actor_mismatch", + "审核人必须与当前登录身份一致。", + status_code=403, + ) + required_permission = ( + "report:publish" if normalized_action == "publish" else "report:review" + ) + if not actor.has_permission(required_permission): + raise ReportGovernanceError( + "report_permission_denied", + f"缺少权限:{required_permission}", + status_code=403, + ) + + current = str(current_status or "").strip().lower() + if current == target: + return target, True + if normalized_action in {"approve", "reject"}: + if current not in REVIEWABLE_STATES: + raise ReportGovernanceError( + "report_transition_conflict", + f"报告状态 {current or 'unknown'} 不允许执行 {normalized_action}。", + status_code=409, + ) + if not str(comment or "").strip(): + raise ReportGovernanceError( + "report_review_comment_required", + "通过或驳回报告必须填写审核意见。", + status_code=400, + ) + creator = str((metadata or {}).get("created_by") or "").strip() + if normalized_action == "approve" and creator and creator == actor.username: + raise ReportGovernanceError( + "report_self_approval_forbidden", + "报告创建人不能审核通过自己的报告。", + status_code=409, + ) + elif current != "approved": + raise ReportGovernanceError( + "report_transition_conflict", + "只有已通过的报告可以发布。", + status_code=409, + ) + if normalized_action == "publish" and bool((metadata or {}).get("is_stale")): + raise ReportGovernanceError( + "stale_report_cannot_publish", + "历史过期报告只能用于审计,不能发布。", + status_code=409, + ) + return target, False + + +def _review_columns(connection) -> set[str]: + rows = connection.execute( + text( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = ANY(current_schemas(false)) + AND table_name = 'report_reviews' + """ + ) + ).mappings().all() + return {str(row.get("column_name")) for row in rows} + + +def transition_report( + report_id: str, + *, + action: str, + actor: CurrentUser, + comment: str = "", + requested_reviewer: str = "", + engine: Engine | None = None, +) -> dict[str, Any]: + database = engine or postgres_engine() + if database is None: + raise ReportGovernanceError( + "report_database_unavailable", + "报告数据库不可用,状态没有变更。", + status_code=503, + ) + try: + with database.begin() as connection: + row = connection.execute( + text( + """ + SELECT report_id, run_id, status, metadata_json + FROM report_runs + WHERE report_id = :report_id + FOR UPDATE + """ + ), + {"report_id": report_id}, + ).mappings().first() + if row is None: + raise ReportGovernanceError( + "report_not_found", "报告不存在。", status_code=404 + ) + report = mapping_dict(row) + metadata = loads_json(report.get("metadata_json"), default={}) + metadata = metadata if isinstance(metadata, dict) else {} + target, idempotent = validate_report_transition( + current_status=str(report.get("status") or ""), + action=action, + actor=actor, + comment=comment, + metadata=metadata, + requested_reviewer=requested_reviewer, + ) + previous = str(report.get("status") or "") + if idempotent: + return { + "ok": True, + "report_id": report_id, + "run_id": report.get("run_id"), + "previous_status": previous, + "status": target, + "action": action, + "actor": actor.username, + "transition_applied": False, + "idempotent": True, + } + + columns = _review_columns(connection) + status_column = "status" if "status" in columns else "action" + comment_column = ( + "review_comment" if "review_comment" in columns else "comment" + ) + required = {"report_id", "reviewer", status_column, comment_column} + if not required.issubset(columns): + raise ReportGovernanceError( + "report_review_schema_incompatible", + "报告审核表结构不兼容,状态没有变更。", + status_code=503, + ) + version = int( + connection.execute( + text( + "SELECT COUNT(*) FROM report_reviews WHERE report_id=:report_id" + ), + {"report_id": report_id}, + ).scalar_one() + or 0 + ) + 1 + event_id = f"report_event_{uuid.uuid4().hex}" + event_metadata = { + "event_id": event_id, + "source": "report_governance_service", + "action": action, + "previous_status": previous, + "status": target, + "actor_user_id": actor.user_id, + "actor_role": actor.role, + "tenant_id": actor.tenant_id, + "workspace_id": actor.workspace_id, + "version": version, + } + fields = ["report_id", "reviewer", status_column, comment_column] + values = [":report_id", ":reviewer", ":event_status", ":comment"] + params: dict[str, Any] = { + "report_id": report_id, + "reviewer": actor.username, + "event_status": target, + "comment": str(comment or "").strip(), + } + if "run_id" in columns: + fields.append("run_id") + values.append(":run_id") + params["run_id"] = report.get("run_id") + if "version" in columns: + fields.append("version") + values.append(":version") + params["version"] = version + if "metadata_json" in columns: + fields.append("metadata_json") + values.append("CAST(:event_metadata AS jsonb)") + params["event_metadata"] = dumps_json(event_metadata) + connection.execute( + text( + f"INSERT INTO report_reviews ({', '.join(fields)}) " + f"VALUES ({', '.join(values)})" + ), + params, + ) + updated = connection.execute( + text( + """ + UPDATE report_runs + SET status=:status, updated_at=CURRENT_TIMESTAMP + WHERE report_id=:report_id AND status=:previous_status + """ + ), + { + "status": target, + "report_id": report_id, + "previous_status": previous, + }, + ) + if updated.rowcount != 1: + raise ReportGovernanceError( + "report_concurrent_transition", + "报告状态已被其他操作更新,请刷新后重试。", + status_code=409, + ) + return { + "ok": True, + "report_id": report_id, + "run_id": report.get("run_id"), + "previous_status": previous, + "status": target, + "action": action, + "actor": actor.username, + "event_id": event_id, + "event_version": version, + "transition_applied": True, + "idempotent": False, + } + except ReportGovernanceError: + raise + except Exception as exc: + raise ReportGovernanceError( + "report_transition_failed", + "报告状态写入失败,事务已回滚。", + status_code=503, + ) from exc + + +def list_report_review_events( + report_id: str, *, engine: Engine | None = None +) -> list[dict[str, Any]]: + database = engine or postgres_engine() + if database is None: + raise ReportGovernanceError( + "report_database_unavailable", "报告数据库不可用。", status_code=503 + ) + try: + with database.connect() as connection: + exists = connection.execute( + text("SELECT 1 FROM report_runs WHERE report_id=:report_id"), + {"report_id": report_id}, + ).first() + if exists is None: + raise ReportGovernanceError( + "report_not_found", "报告不存在。", status_code=404 + ) + columns = _review_columns(connection) + status_column = "status" if "status" in columns else "action" + comment_column = ( + "review_comment" if "review_comment" in columns else "comment" + ) + updated_column = "updated_at" if "updated_at" in columns else "created_at" + version_expression = "version" if "version" in columns else "NULL AS version" + metadata_expression = ( + "metadata_json" if "metadata_json" in columns else "NULL AS metadata_json" + ) + rows = connection.execute( + text( + f""" + SELECT report_id, {status_column} AS status, reviewer, + {comment_column} AS review_comment, + {version_expression}, {metadata_expression}, + created_at, {updated_column} AS updated_at + FROM report_reviews + WHERE report_id=:report_id + ORDER BY {updated_column} DESC, created_at DESC + """ + ), + {"report_id": report_id}, + ).mappings().all() + return mapping_list(rows) + except ReportGovernanceError: + raise + except Exception as exc: + raise ReportGovernanceError( + "report_reviews_read_failed", "报告审核记录读取失败。", status_code=503 + ) from exc diff --git a/backend/app/services/task_business_handlers.py b/backend/app/services/task_business_handlers.py index be537fc..7fc07dc 100644 --- a/backend/app/services/task_business_handlers.py +++ b/backend/app/services/task_business_handlers.py @@ -336,6 +336,8 @@ def run_report_daily(payload: dict[str, Any], context: TaskStepLogger | None = N report_date = str(payload.get("report_date") or "") or None region = str(payload.get("region") or "模型覆盖市场") report_type = str(payload.get("report_type") or "daily") + source_report_id = str(payload.get("source_report_id") or "").strip() or None + requested_by = str(payload.get("requested_by") or "").strip() if context: context.log( "parse", @@ -350,6 +352,8 @@ def run_report_daily(payload: dict[str, Any], context: TaskStepLogger | None = N report_type=report_type, region=region, report_date=report_date, + source_report_id=source_report_id, + requested_by=requested_by, ) if context: context.log("generate", "生成可追溯运营决策报告", progress=72, metadata={"report_id": result["report_id"], "run_id": result["run_id"]}) diff --git a/backend/app/workers/dispatcher.py b/backend/app/workers/dispatcher.py index a60da11..e93e644 100644 --- a/backend/app/workers/dispatcher.py +++ b/backend/app/workers/dispatcher.py @@ -10,11 +10,17 @@ from backend.app.observability import log_suppressed_exception from backend.app.repositories.task_repository import append_task_log, find_active_idempotent_task, save_task_record, task_runtime_summary from backend.app.services.task_runtime import idempotency_key_for, normalize_task_kind, queue_for_kind, task_policy +from backend.app.services.forecast_transaction_service import generate_run_id from backend.app.task_manager import task_manager from backend.app.workers.task_commands import command_for_kind -SPECIALIZED_TASK_KINDS = {"knowledge_import", "embedding_refresh", "report_generate"} +SPECIALIZED_TASK_KINDS = { + "knowledge_import", + "embedding_refresh", + "report_generate", + "health_check", +} BUSINESS_TASK_KINDS = {"price_predict", "data_sync", "report_daily"} PYTHON_TASK_KINDS = SPECIALIZED_TASK_KINDS | BUSINESS_TASK_KINDS @@ -78,16 +84,16 @@ def enqueue_task(kind: str, payload: dict[str, Any] | None = None) -> dict[str, return _enqueue_specialized_task(kind, payload, mode=mode) queue_name = queue_for_kind(kind) is_queue_available = mode != "local_thread" and celery_queue_available(queue_name) - if mode == "celery" and not is_queue_available: + if mode in {"auto", "celery"} and not is_queue_available: raise RuntimeError( - f"TASK_EXECUTION_MODE=celery requires an active consumer for queue '{queue_name}'." + f"TASK_EXECUTION_MODE={mode} requires an active consumer for queue '{queue_name}'." ) - if mode == "local_thread" or (mode == "auto" and not is_queue_available): + if mode == "local_thread": return task_manager.start(kind) from backend.app.workers.tasks import run_command_task, sync_core_data_task - run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + run_id = generate_run_id() task_id = "task_" + uuid.uuid4().hex[:12] policy = task_policy(kind) dedupe_window = int(payload.get("dedupe_window_seconds") or policy.dedupe_window_seconds) @@ -119,30 +125,38 @@ def enqueue_task(kind: str, payload: dict[str, Any] | None = None) -> dict[str, "error_message": "", "execution_mode": "celery", } - save_task_record(record, status="pending", log_text="task accepted") + if not save_task_record(record, status="pending", log_text="task accepted"): + raise RuntimeError("PostgreSQL task state persistence is unavailable; task was not submitted.") append_task_log(task_id, level="info", step="prepare", message="task accepted", status="pending", run_id=run_id, task_name=kind, task_kind=kind, metadata={"queue_name": queue_name}) - if kind == "sync_core_data": - async_result = sync_core_data_task.apply_async(args=(task_id, run_id), queue=queue_name) - else: - async_result = run_command_task.apply_async(args=(kind, task_id, run_id), queue=queue_name) + try: + if kind == "sync_core_data": + async_result = sync_core_data_task.apply_async(args=(task_id, run_id), queue=queue_name) + else: + async_result = run_command_task.apply_async(args=(kind, task_id, run_id), queue=queue_name) + except Exception as exc: + _mark_dispatch_failed(record, exc) + raise RuntimeError(f"Failed to submit task to active queue '{queue_name}'.") from exc record["celery_task_id"] = async_result.id record["execution_backend"] = "celery" record["execution_mode"] = "celery" - save_task_record(record, status="pending", log_text="task submitted to celery") - append_task_log(task_id, level="info", step="prepare", message="task submitted to celery", status="pending", run_id=run_id, task_name=kind, task_kind=kind, metadata={"celery_task_id": async_result.id, "queue_name": queue_name}) + record["status"] = "queued" + record["message"] = "task submitted to celery" + if not save_task_record(record, status="queued", log_text="task submitted to celery"): + raise RuntimeError(f"Task was submitted to queue '{queue_name}' but its Celery identity could not be persisted.") + append_task_log(task_id, level="info", step="prepare", message="task submitted to celery", status="queued", run_id=run_id, task_name=kind, task_kind=kind, metadata={"celery_task_id": async_result.id, "queue_name": queue_name}) return jsonable(record) def _enqueue_specialized_task(kind: str, payload: dict[str, Any], *, mode: str) -> dict[str, Any]: kind = normalize_task_kind(kind) - run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + run_id = generate_run_id() task_id = "task_" + uuid.uuid4().hex[:12] policy = task_policy(kind) queue_name = queue_for_kind(kind) is_queue_available = mode != "local_thread" and celery_queue_available(queue_name) - if mode == "celery" and not is_queue_available: + if mode in {"auto", "celery"} and not is_queue_available: raise RuntimeError( - f"TASK_EXECUTION_MODE=celery requires an active consumer for queue '{queue_name}'." + f"TASK_EXECUTION_MODE={mode} requires an active consumer for queue '{queue_name}'." ) dedupe_window = int(payload.get("dedupe_window_seconds") or policy.dedupe_window_seconds) idempotency_key, payload_hash = idempotency_key_for(kind, payload) @@ -181,30 +195,15 @@ def _enqueue_specialized_task(kind: str, payload: dict[str, Any], *, mode: str) "queue_name": queue_name, "metadata": metadata, } - if mode == "auto" and not is_queue_available and kind in BUSINESS_TASK_KINDS: - record["execution_mode"] = "db_pending" - save_task_record(record, status="pending", log_text="Celery/Redis unavailable; task kept in PostgreSQL pending queue") - append_task_log( - task_id, - level="warning", - step="prepare", - message="Celery/Redis unavailable; task kept in PostgreSQL pending queue", - status="pending", - run_id=run_id, - task_name=kind, - task_kind=kind, - metadata={"queue_name": queue_name}, - ) - record["execution_backend"] = "db_pending" - record["dispatch_fallback_reason"] = "Celery/Redis unavailable" - return jsonable(record) execution_mode = "celery" if mode == "celery" or (mode == "auto" and is_queue_available) else "local_thread" record["execution_mode"] = execution_mode - save_task_record(record, status="pending", log_text=f"task queued via {execution_mode}") + if not save_task_record(record, status="pending", log_text=f"task queued via {execution_mode}"): + raise RuntimeError("PostgreSQL task state persistence is unavailable; task was not submitted.") append_task_log(task_id, level="info", step="prepare", message=f"task queued via {execution_mode}", status="pending", run_id=run_id, task_name=kind, task_kind=kind, metadata={"queue_name": queue_name}) if execution_mode == "celery": from backend.app.workers.tasks import ( embedding_refresh_task, + health_check_task, knowledge_import_task, report_generate_task, run_data_sync_task, @@ -216,20 +215,29 @@ def _enqueue_specialized_task(kind: str, payload: dict[str, Any], *, mode: str) "knowledge_import": knowledge_import_task, "embedding_refresh": embedding_refresh_task, "report_generate": report_generate_task, + "health_check": health_check_task, "price_predict": run_price_predict_task, "data_sync": run_data_sync_task, "report_daily": run_report_daily_task, } - async_result = task_map[kind].apply_async(args=(task_id, run_id, payload), queue=queue_name) + try: + async_result = task_map[kind].apply_async(args=(task_id, run_id, payload), queue=queue_name) + except Exception as exc: + _mark_dispatch_failed(record, exc) + raise RuntimeError(f"Failed to submit task to active queue '{queue_name}'.") from exc record["celery_task_id"] = async_result.id record["execution_backend"] = "celery" record["execution_mode"] = "celery" - save_task_record(record, status="pending", log_text="task submitted to celery") - append_task_log(task_id, level="info", step="prepare", message="task submitted to celery", status="pending", run_id=run_id, task_name=kind, task_kind=kind, metadata={"celery_task_id": async_result.id, "queue_name": queue_name}) + record["status"] = "queued" + record["message"] = "task submitted to celery" + if not save_task_record(record, status="queued", log_text="task submitted to celery"): + raise RuntimeError(f"Task was submitted to queue '{queue_name}' but its Celery identity could not be persisted.") + append_task_log(task_id, level="info", step="prepare", message="task submitted to celery", status="queued", run_id=run_id, task_name=kind, task_kind=kind, metadata={"celery_task_id": async_result.id, "queue_name": queue_name}) return jsonable(record) from backend.app.workers.tasks import ( embedding_refresh_task, + health_check_task, knowledge_import_task, report_generate_task, run_data_sync_task, @@ -241,19 +249,56 @@ def _enqueue_specialized_task(kind: str, payload: dict[str, Any], *, mode: str) "knowledge_import": knowledge_import_task, "embedding_refresh": embedding_refresh_task, "report_generate": report_generate_task, + "health_check": health_check_task, "price_predict": run_price_predict_task, "data_sync": run_data_sync_task, "report_daily": run_report_daily_task, } thread = threading.Thread(target=task_map[kind], args=(task_id, run_id, payload), daemon=True) - thread.start() + try: + thread.start() + except Exception as exc: + _mark_dispatch_failed(record, exc) + raise RuntimeError("Failed to start the explicitly configured local task thread.") from exc record["execution_backend"] = "local_thread" record["execution_mode"] = "local_thread" - save_task_record(record, status="pending", log_text="task submitted to local_thread") + if not save_task_record(record, status="pending", log_text="task submitted to local_thread"): + raise RuntimeError("Local task thread started but its runtime identity could not be persisted.") append_task_log(task_id, level="info", step="prepare", message="task submitted to local_thread", status="pending", run_id=run_id, task_name=kind, task_kind=kind, metadata={"queue_name": queue_name}) return jsonable(record) +def _mark_dispatch_failed(record: dict[str, Any], exc: Exception) -> None: + finished_at = datetime.now().isoformat(sep=" ", timespec="seconds") + record.update({ + "status": "failed", + "message": "task dispatch failed", + "error_code": "TASK_DISPATCH_FAILED", + "error_message": "task dispatch failed before worker execution", + "error_detail": str(exc)[:300], + "finished_at": finished_at, + "ended_at": finished_at, + }) + save_task_record(record, status="failed", log_text="task dispatch failed before worker execution") + append_task_log( + str(record.get("task_id") or ""), + level="error", + step="prepare", + message="task dispatch failed before worker execution", + status="failed", + run_id=str(record.get("run_id") or ""), + task_name=str(record.get("kind") or ""), + task_kind=str(record.get("kind") or ""), + metadata={"queue_name": record.get("queue_name"), "error_code": "TASK_DISPATCH_FAILED"}, + ) + log_suppressed_exception( + "worker.task_dispatch_failed", + exc, + task_id=record.get("task_id"), + queue_name=record.get("queue_name"), + ) + + def task_runtime_health() -> dict[str, Any]: mode = task_execution_mode() redis_ok = redis_available() @@ -262,10 +307,26 @@ def task_runtime_health() -> dict[str, Any]: celery_runtime = _celery_runtime_snapshot() if broker_ok else {"available": False, "source": "runtime_check_failed"} celery_ok = bool(celery_runtime.get("available")) runtime_workers = celery_runtime.get("active_workers") or [] + active_queues = celery_runtime.get("active_queues") or [] db_workers = summary.get("active_workers") or [] - worker_names = runtime_workers if celery_ok else db_workers + required_kinds = { + "knowledge_import", + "embedding_refresh", + "report_generate", + "sync_core_data", + "price_predict", + "report_daily", + "health_check", + } + required_queues = sorted({queue_for_kind(kind) for kind in required_kinds}) + missing_queues = sorted(set(required_queues) - set(active_queues)) + dispatch_ready = bool( + mode == "local_thread" or (celery_ok and not missing_queues) + ) return { - "ok": bool(mode != "celery" or celery_ok), + "ok": dispatch_ready, + "dispatch_ready": dispatch_ready, + "worker_required": mode in {"auto", "celery"}, "execution_mode": mode, "redis": {"ok": redis_ok, "status": "connected" if redis_ok else "unavailable"}, "celery": { @@ -277,18 +338,29 @@ def task_runtime_health() -> dict[str, Any]: "active_count": celery_runtime.get("active_count", 0), "reserved_count": celery_runtime.get("reserved_count", 0), "scheduled_count": celery_runtime.get("scheduled_count", 0), - "active_queues": celery_runtime.get("active_queues") or [], + "active_queues": active_queues, + "required_queues": required_queues, + "missing_queues": missing_queues, "db_snapshot_workers": db_workers, }, "celery_available": celery_ok, - "active_workers": worker_names, + "active_workers": runtime_workers, "queue_summary": summary.get("queue_summary") or [], "status_counts": summary.get("status_counts") or {}, "running_task_count": int(summary.get("running_task_count") or 0), "pending_task_count": int(summary.get("pending_task_count") or 0), "failed_task_count": int(summary.get("failed_task_count") or 0), "timeout_task_count": int(summary.get("timeout_task_count") or 0), - "message": "ok" if mode != "celery" or celery_ok else "TASK_EXECUTION_MODE=celery but Celery/Redis is unavailable", + "message": ( + "ok" + if dispatch_ready + else ( + f"TASK_EXECUTION_MODE={mode} but required Celery queues are unavailable: " + + ", ".join(missing_queues) + if celery_ok and missing_queues + else f"TASK_EXECUTION_MODE={mode} but no active Celery worker is available" + ) + ), } diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 134a3d1..299b979 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -151,7 +151,11 @@ def run_command_task(kind: str, task_id: str, run_id: str) -> dict[str, Any]: started = datetime.now() command = command_for_kind(kind) policy = task_policy(kind) - timeout_seconds = _task_timeout_seconds(kind, task_id) + accepted_record = _current_record(task_id) + task_payload = dict(accepted_record.get("payload") or {}) + accepted_queue_name = str(accepted_record.get("queue_name") or queue_for_kind(kind)) + accepted_celery_task_id = str(accepted_record.get("celery_task_id") or "") + timeout_seconds = int(accepted_record.get("timeout_seconds") or policy.timeout_seconds) timeout_at_dt = timeout_at(started, timeout_seconds) paths = project_paths() paths.log_dir.mkdir(parents=True, exist_ok=True) @@ -168,8 +172,12 @@ def run_command_task(kind: str, task_id: str, run_id: str) -> dict[str, Any]: progress=10, message="running", worker_id=socket.gethostname(), + celery_task_id=accepted_celery_task_id, timeout_seconds=timeout_seconds, timeout_at_value=timeout_at_dt, + payload=task_payload, + metadata=dict(accepted_record.get("metadata") or {}), + queue_name=accepted_queue_name, ), status="running", ) @@ -177,6 +185,10 @@ def run_command_task(kind: str, task_id: str, run_id: str) -> dict[str, Any]: env = os.environ.copy() env["PYTHONUTF8"] = "1" env["NO_PAUSE"] = "1" + env["PIPELINE_RUN_ID"] = run_id + env["PIPELINE_TASK_ID"] = task_id + if kind == "retrain_model": + env["ENABLE_ROLLING_BACKTEST"] = "1" if bool(task_payload.get("rolling_backtest")) else "0" start_counter = time.perf_counter() with open(log_path, "w", encoding="utf-8", errors="replace") as log_file: log_file.write(f"[{started.isoformat(sep=' ', timespec='seconds')}] Celery task started: {kind}\n") @@ -250,9 +262,13 @@ def run_command_task(kind: str, task_id: str, run_id: str) -> dict[str, Any]: message=status, result_ref=str(log_path), worker_id=socket.gethostname(), + celery_task_id=accepted_celery_task_id, timeout_seconds=timeout_seconds, timeout_at_value=timeout_at_dt, max_retries=policy.max_retries, + payload=task_payload, + metadata=dict(accepted_record.get("metadata") or {}), + queue_name=accepted_queue_name, cancel_reason="cancel requested" if status == "cancelled" else "", cancelled_at=ended if status == "cancelled" else None, ) @@ -518,8 +534,35 @@ def run_report_daily_task(task_id: str, run_id: str, payload: dict[str, Any] | N @_task_decorator("power_trading.health_check_task") -def health_check_task() -> dict[str, Any]: - return {"status": "ok", "worker_id": socket.gethostname(), "checked_at": datetime.now().isoformat(sep=" ", timespec="seconds")} +def health_check_task( + task_id: str = "", + run_id: str = "", + payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + def probe(_payload: dict[str, Any], context: TaskExecutionContext | None = None) -> dict[str, Any]: + result = { + "status": "ok", + "worker_id": socket.gethostname(), + "checked_at": datetime.now().isoformat(sep=" ", timespec="seconds"), + } + if context is not None: + context.log( + "execute", + "worker health probe completed", + progress=90, + metadata=result, + ) + return result + + if not task_id: + return probe(payload or {}) + return _run_python_task( + task_id=task_id, + run_id=run_id or datetime.now().strftime("%Y%m%d_%H%M%S"), + kind="health_check", + payload=payload, + handler=probe, + ) @_task_decorator("power_trading.sync_core_data_task") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 40de3b2..a92e3f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "power-trading-ai-platform", - "version": "2.12.1", + "version": "2.13.0-rc1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "power-trading-ai-platform", - "version": "2.12.1", + "version": "2.13.0-rc1", "engines": { "node": ">=18 <25", "npm": ">=9 <12" diff --git a/frontend/package.json b/frontend/package.json index 612cae5..a5f8455 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "power-trading-ai-platform", - "version": "2.12.1", + "version": "2.13.0-rc1", "private": true, "type": "module", "engines": { diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 3680f67..e151832 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -9,6 +9,12 @@ import { LoginPage } from '../pages/LoginPage'; import { getDefaultChildKey, normalizeRouteState, routeTitles } from './router'; import type { PageProps, RouteKey, RouteState } from '../types/ui'; +const compactNavigationQuery = '(max-width: 900px)'; + +function isCompactNavigationViewport() { + return window.matchMedia(compactNavigationQuery).matches; +} + const pages: Record JSX.Element>> = { dashboard: lazy(() => import('../pages/dashboard/DashboardPage').then((mod) => ({ default: mod.DashboardPage }))), data: lazy(() => import('../pages/data/DataCenterPage').then((mod) => ({ default: mod.DataCenterPage }))), @@ -25,15 +31,37 @@ const pages: Record JSX.Elem export function App() { const { authRequired, loginRequested, loading: authLoading, isAuthenticated } = useAuth(); const [routeState, setRouteState] = useState(() => normalizeRouteState(window.location.hash)); - const [collapsed, setCollapsed] = useState(false); + const [compactNavigation, setCompactNavigation] = useState(isCompactNavigationViewport); + const [collapsed, setCollapsed] = useState(isCompactNavigationViewport); useEffect(() => { - const handleHashChange = () => setRouteState(normalizeRouteState(window.location.hash)); + const media = window.matchMedia(compactNavigationQuery); + const handleHashChange = () => { + setRouteState(normalizeRouteState(window.location.hash)); + if (media.matches) setCollapsed(true); + }; + const handleViewportChange = (event: MediaQueryListEvent) => { + setCompactNavigation(event.matches); + setCollapsed(event.matches); + }; window.addEventListener('hashchange', handleHashChange); + media.addEventListener('change', handleViewportChange); if (!window.location.hash) window.location.hash = `/dashboard/${getDefaultChildKey('dashboard')}`; - return () => window.removeEventListener('hashchange', handleHashChange); + return () => { + window.removeEventListener('hashchange', handleHashChange); + media.removeEventListener('change', handleViewportChange); + }; }, []); + useEffect(() => { + if (!compactNavigation || collapsed) return undefined; + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setCollapsed(true); + }; + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [collapsed, compactNavigation]); + const title = useMemo(() => routeTitles[routeState.route], [routeState.route]); const ActivePage = pages[routeState.route]; @@ -50,6 +78,7 @@ export function App() { const nextState = { route: nextRoute, childKey }; window.location.hash = `/${nextRoute}/${childKey}`; setRouteState(nextState); + if (compactNavigation) setCollapsed(true); } return ( @@ -57,6 +86,7 @@ export function App() { route={routeState.route} activeSubKey={routeState.childKey} collapsed={collapsed} + compactNavigation={compactNavigation} onCollapse={() => setCollapsed((value) => !value)} onNavigate={handleNavigate} > diff --git a/frontend/src/components/common/States.tsx b/frontend/src/components/common/States.tsx index abd88ca..3c3ce02 100644 --- a/frontend/src/components/common/States.tsx +++ b/frontend/src/components/common/States.tsx @@ -204,6 +204,9 @@ export function SourceContextPanel({ /> ); } + const batchStatus = meta.is_stale || ['historical', 'stale'].includes(String(meta.freshness_status || '')) + ? '需更新' + : '可使用'; return (
@@ -218,6 +221,7 @@ export function SourceContextPanel({
业务时间{timeText(meta.updated_at || meta.generated_at)} 更新时间{timeText(lastRefreshedAt)} + 批次状态{batchStatus}
); diff --git a/frontend/src/components/strategy/StrategyDesign.tsx b/frontend/src/components/strategy/StrategyDesign.tsx index a6f980a..a63e05f 100644 --- a/frontend/src/components/strategy/StrategyDesign.tsx +++ b/frontend/src/components/strategy/StrategyDesign.tsx @@ -152,7 +152,7 @@ function OverviewInsight({ data }: { data: any }) { return (

策略建议说明

{data?.strategyUsable ? '人工复核后参考' : '发布门禁未通过'}需人工确认
- {data?.strategyUsable ? '已形成通过治理门禁的策略记录;实际执行前仍须结合合同、设备与当前市场复核。' : `当前记录状态为${data?.strategyStatusLabel || '不可用'};使用前请完成业务复核和发布门禁校验。`} + {data?.strategyUsable ? '已形成通过治理门禁的策略记录;实际执行前仍须结合合同、设备与当前市场复核。' : `当前记录状态为${data?.strategyStatusLabel || '不可用'},不可作为当前策略;使用前请完成业务复核和发布门禁校验。`}
  • 低价候选时段:{lowLabel}
  • 高风险候选时段:{highLabel}
  • 储能动作仅作为辅助决策建议。
@@ -449,7 +449,7 @@ function ReviewDetail({ row, onAction, permissions, reviewHistory }: { useEffect(() => setComment(''), [row?.key]); return (
-

复核详情

仅决策支持,不自动执行
+

复核详情

只读事实,不自动交易、不控制设备
{row ? ( <>
@@ -471,7 +471,7 @@ function ReviewDetail({ row, onAction, permissions, reviewHistory }: { : null} - {row.status === 'approved' && permissions.canPublish ? : null} + {row.status === 'approved' && permissions.canPublish ? : null} {!['draft', 'pending_review', 'approved'].includes(row.status) && 该状态无可用人工动作}
diff --git a/frontend/src/features/globalAssistant/GlobalAssistantDrawer.tsx b/frontend/src/features/globalAssistant/GlobalAssistantDrawer.tsx index 85134d1..5d82782 100644 --- a/frontend/src/features/globalAssistant/GlobalAssistantDrawer.tsx +++ b/frontend/src/features/globalAssistant/GlobalAssistantDrawer.tsx @@ -74,7 +74,9 @@ export function GlobalAssistantDrawer({ for (let attempt = 0; attempt < 15 && !pollingTerminal.has(current.status); attempt += 1) { await wait(1200); try { - const record = await getAssistantAttachment(current.attachment_id, current.session_id || state.sessionId || undefined); + const attachmentSessionId = current.session_id || state.sessionId; + if (!attachmentSessionId) throw new Error('附件缺少会话标识,无法读取。'); + const record = await getAssistantAttachment(current.attachment_id, attachmentSessionId); current = { ...current, ...record }; globalAssistantStore.replaceAttachment(clientId, current); } catch (error) { @@ -116,7 +118,9 @@ export function GlobalAssistantDrawer({ async function removeAttachment(attachment: GlobalAssistantAttachment) { if (attachment.attachment_id) { try { - await deleteAssistantAttachment(attachment.attachment_id); + const attachmentSessionId = attachment.session_id || state.sessionId; + if (!attachmentSessionId) throw new Error('附件缺少会话标识,无法删除。'); + await deleteAssistantAttachment(attachment.attachment_id, attachmentSessionId); } catch (error) { message.warning(errorText(error)); return; diff --git a/frontend/src/layout/BasicLayout.tsx b/frontend/src/layout/BasicLayout.tsx index 63eba36..1822414 100644 --- a/frontend/src/layout/BasicLayout.tsx +++ b/frontend/src/layout/BasicLayout.tsx @@ -11,12 +11,13 @@ interface BasicLayoutProps { route: RouteKey; activeSubKey: string; collapsed: boolean; + compactNavigation: boolean; onCollapse: () => void; onNavigate: (route: RouteKey, childKey?: string) => void; children: ReactNode; } -export function BasicLayout({ route, activeSubKey, collapsed, onCollapse, onNavigate, children }: BasicLayoutProps) { +export function BasicLayout({ route, activeSubKey, collapsed, compactNavigation, onCollapse, onNavigate, children }: BasicLayoutProps) { const dataWorkspace = route === 'data'; const { canAccessRoute, permissionSnapshotHash } = useAuth(); const group = menuGroups.find((item) => item.key === route); @@ -25,9 +26,28 @@ export function BasicLayout({ route, activeSubKey, collapsed, onCollapse, onNavi const routeKey = `${route}.${activeSubKey.replace(`${route}-`, '').replace(/-/g, '_')}`; return ( - + - + + {compactNavigation && !collapsed ? ( + +
回滚将替换当前 Active 模型,请确认后操作。
diff --git a/frontend/src/pages/report/ReportCenterPage.tsx b/frontend/src/pages/report/ReportCenterPage.tsx index 496e59f..1db2acb 100644 --- a/frontend/src/pages/report/ReportCenterPage.tsx +++ b/frontend/src/pages/report/ReportCenterPage.tsx @@ -113,7 +113,7 @@ function buildPriceOption(rows: any[], review = false) { } export function ReportCenterPage({ activeSubKey, onSubNavigate }: PageProps) { - const { message } = App.useApp(); + const { message, modal } = App.useApp(); const { canPerformAction, user } = useAuth(); const isReviewPage = activeSubKey === 'report-review' || activeSubKey === 'report-publish'; const pageTitle = isReviewPage @@ -133,7 +133,10 @@ export function ReportCenterPage({ activeSubKey, onSubNavigate }: PageProps) { const permissions = { canDownload: canPerformAction('report:download'), canGenerate: canPerformAction('report.generate'), - canReview: canPerformAction('report:review') + canReview: canPerformAction('report:review'), + canApprove: canPerformAction('report.approve'), + canReject: canPerformAction('report.reject'), + canPublish: canPerformAction('report.publish') }; async function loadData(nextKeyword = keyword, nextPage = reportPage, nextType = reportType, nextStatus = reportStatus) { @@ -184,6 +187,14 @@ export function ReportCenterPage({ activeSubKey, onSubNavigate }: PageProps) { : visibleReports.find((item: any) => item.report_id === selectedId) || visibleReports[0] || (!reviewer ? data.activeReport : null), [visibleReports, reviewer, selectedId, data.activeReport] ); + const activeStatus = String(activeReport?.status || '').toLowerCase(); + const isReviewable = ['draft', 'ready', 'pending', 'reviewing'].includes(activeStatus); + const actionState = { + canApproveCurrent: permissions.canApprove && isReviewable, + canRejectCurrent: permissions.canReject && isReviewable, + canPublishCurrent: permissions.canPublish && activeStatus === 'approved' && !activeReport?.isStale, + canRegenerateCurrent: permissions.canGenerate && activeStatus === 'rejected' + }; useEffect(() => { const selected = visibleReports.find((item: any) => item.report_id === selectedId); if (!selected) return; @@ -230,36 +241,83 @@ export function ReportCenterPage({ activeSubKey, onSubNavigate }: PageProps) { async function generateReport() { const res = await api.generateReport(); - message.success(`报告生成任务已启动:${res.task_id || res.run_id || 'report_generate'}`); + message.success(`报告生成任务已创建:${res.task_id || res.run_id || 'report_generate'}`); await loadData(); } async function regenerateReport() { if (!activeReport?.report_id) return; const res = await api.regenerateReport(activeReport.report_id); - message.success(`重新生成任务已启动:${res.task_id || res.run_id || activeReport.report_id}`); + message.success(`重新生成任务已接受:${res.task_id || res.run_id || activeReport.report_id}`); await loadData(); } + function confirmRegenerate() { + if (!actionState.canRegenerateCurrent) { + message.warning('只有已驳回报告且具备生成权限时才能重新生成新版本'); + return; + } + modal.confirm({ + title: '确认重新生成报告新版本?', + content: `来源报告:${activeReport?.report_id || '--'}。操作只创建异步任务,执行结果需在任务中心核验。`, + okText: '创建任务', + cancelText: '取消', + onOk: regenerateReport + }); + } + async function review(action: 'approve' | 'reject' | 'publish') { if (!activeReport?.report_id) return; - if (action === 'reject' && !reviewComment.trim()) { - message.warning('驳回报告需要填写审核意见'); + if (action !== 'publish' && !reviewComment.trim()) { + message.warning('通过或驳回报告必须填写审核意见'); return; } if (!user?.username) { message.error('当前登录身份不可用,无法写入可追溯审核记录'); return; } - const payload = { reviewer: user.username, review_comment: reviewComment || (action === 'approve' ? '审核通过' : '发布归档') }; - if (action === 'approve') await api.approveReport(activeReport.report_id, payload); - if (action === 'reject') await api.rejectReport(activeReport.report_id, payload); - if (action === 'publish') await api.publishReport(activeReport.report_id, payload); - message.success('报告状态已更新'); + const payload = { reviewer: user.username, review_comment: reviewComment.trim() }; + let result: any; + if (action === 'approve') result = await api.approveReport(activeReport.report_id, payload); + if (action === 'reject') result = await api.rejectReport(activeReport.report_id, payload); + if (action === 'publish') result = await api.publishReport(activeReport.report_id, payload); + const statusLabel = action === 'approve' ? '已通过' : action === 'reject' ? '已驳回' : '已发布'; + if (result?.audit_log_persisted === false) { + message.warning(`报告${statusLabel},治理事件已入库,但中央审计日志写入失败`); + } else if (result?.idempotent) { + message.info(`报告已经处于“${statusLabel}”状态,本次未重复写入`); + } else { + message.success(`报告状态已更新为“${statusLabel}”`); + } setReviewComment(''); await loadData(); } + function confirmReview(action: 'approve' | 'reject' | 'publish') { + const allowed = action === 'approve' + ? actionState.canApproveCurrent + : action === 'reject' + ? actionState.canRejectCurrent + : actionState.canPublishCurrent; + if (!allowed) { + message.warning('当前报告状态或当前身份不允许执行此操作'); + return; + } + if (action !== 'publish' && !reviewComment.trim()) { + message.warning('通过或驳回报告必须填写审核意见'); + return; + } + const actionLabel = action === 'approve' ? '通过' : action === 'reject' ? '驳回' : '发布'; + modal.confirm({ + title: `确认${actionLabel}这份报告?`, + content: `报告:${activeReport?.title || activeReport?.report_id || '--'}。该操作将写入状态机与不可省略的治理事件。`, + okText: `确认${actionLabel}`, + cancelText: '取消', + okButtonProps: { danger: action === 'reject' }, + onOk: () => review(action) + }); + } + async function downloadReport() { if (!activeReport?.report_id) return; const { blob, filename } = await api.reportDownload(activeReport.report_id); @@ -358,12 +416,13 @@ export function ReportCenterPage({ activeSubKey, onSubNavigate }: PageProps) { reviews={data.reviews || []} reviewComment={reviewComment} setReviewComment={setReviewComment} - onApprove={() => review('approve')} - onReject={() => review('reject')} - onPublish={() => review('publish')} - onRegenerate={regenerateReport} + onApprove={() => confirmReview('approve')} + onReject={() => confirmReview('reject')} + onPublish={() => confirmReview('publish')} + onRegenerate={confirmRegenerate} onDownload={downloadReport} permissions={permissions} + actionState={actionState} onFullscreenError={() => message.error('当前浏览器未允许全屏显示')} /> ) : showContent ? ( @@ -380,10 +439,11 @@ export function ReportCenterPage({ activeSubKey, onSubNavigate }: PageProps) { previewMetrics={data.previewMetrics || []} risks={data.risks || []} onDownload={downloadReport} - onRegenerate={regenerateReport} + onRegenerate={confirmRegenerate} onCopyLink={copyLink} - onReview={() => onSubNavigate?.('report-review')} + onReview={() => onSubNavigate?.(permissions.canApprove || permissions.canReject ? 'report-review' : 'report-publish')} permissions={permissions} + actionState={actionState} /> ) : null} @@ -511,7 +571,7 @@ function ReportStatusKpiRow({ items, loading }: { items: any[]; loading: boolean ); } -function ReportPreviewView({ reports, total, page, onPageChange, onReload, activeReport, selectedId, setSelectedId, curve, previewMetrics, risks, onDownload, onRegenerate, onCopyLink, onReview, permissions }: any) { +function ReportPreviewView({ reports, total, page, onPageChange, onReload, activeReport, selectedId, setSelectedId, curve, previewMetrics, risks, onDownload, onRegenerate, onCopyLink, onReview, permissions, actionState }: any) { const [summaryExpanded, setSummaryExpanded] = useState(false); return (
@@ -558,14 +618,14 @@ function ReportPreviewView({ reports, total, page, onPageChange, onReload, activ
- +
); } -function ReviewPublishView({ reports, total, page, onPageChange, onReload, activeReport, selectedId, setSelectedId, curve, previewMetrics, reviews, reviewComment, setReviewComment, onApprove, onReject, onPublish, onRegenerate, onDownload, permissions, onFullscreenError }: any) { +function ReviewPublishView({ reports, total, page, onPageChange, onReload, activeReport, selectedId, setSelectedId, curve, previewMetrics, reviews, reviewComment, setReviewComment, onApprove, onReject, onPublish, onRegenerate, onDownload, permissions, actionState, onFullscreenError }: any) { const previewRef = useRef(null); return (
@@ -602,12 +662,12 @@ function ReviewPublishView({ reports, total, page, onPageChange, onReload, activ
待审核待发布
- setReviewComment(event.target.value)} placeholder="请输入审核意见(选填)..." /> + setReviewComment(event.target.value)} placeholder="通过或驳回时必须填写;发布时可选" />
- {permissions.canReview ? : null} - {permissions.canReview ? : null} - {permissions.canGenerate ? : null} - {permissions.canReview ? : null} + {permissions.canApprove ? : null} + {permissions.canReject ? : null} + {permissions.canGenerate ? : null} + {permissions.canPublish ? : null}
@@ -646,16 +706,16 @@ function MiniMetric({ label, value, unit }: any) { ); } -function QuickActions({ onDownload, onRegenerate, onCopyLink, onReview, permissions }: any) { +function QuickActions({ onDownload, onRegenerate, onCopyLink, onReview, permissions, actionState }: any) { const disabledTip = '当前业务流程不支持此操作'; return (
- {permissions.canDownload ? : null} + {permissions.canDownload ? : null} - {permissions.canReview ? : null} - {permissions.canGenerate ? : null} + {permissions.canApprove || permissions.canReject || permissions.canPublish ? : null} + {permissions.canGenerate ? : null} diff --git a/frontend/src/pages/report/report-center-layout.css b/frontend/src/pages/report/report-center-layout.css index 4cbf741..1e261df 100644 --- a/frontend/src/pages/report/report-center-layout.css +++ b/frontend/src/pages/report/report-center-layout.css @@ -902,7 +902,7 @@ .report-workbench .report-process-steps small { width: 100%; overflow: hidden; - font-size: 10px; + font-size: 12px; line-height: 14px; text-overflow: ellipsis; white-space: nowrap; @@ -1076,3 +1076,117 @@ padding-block: 6px; } } + +@media (max-width: 900px) { + .report-workbench { + --report-toolbar-height: auto; + --report-kpi-height: auto; + height: auto; + min-height: calc(100dvh - var(--header-height) - 24px); + grid-template-rows: auto; + overflow: visible; + } + + .report-workbench .report-page-toolbar { + height: auto; + min-height: 0; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto auto; + align-items: stretch; + padding: 10px; + } + + .report-workbench .report-filter-bar, + .report-workbench .report-filter-controls, + .report-workbench .report-filter-lower, + .report-workbench .report-filter-actions { + width: 100%; + min-width: 0; + flex-wrap: wrap; + overflow: visible; + } + + .report-workbench .report-filter-bar label, + .report-workbench .report-filter-bar .ant-select, + .report-workbench .report-filter-bar.is-review .ant-select, + .report-workbench .report-filter-search, + .report-workbench .report-filter-bar.is-review .report-filter-search { + width: 100%; + min-width: 0; + } + + .report-workbench .report-status-kpi-row, + .report-workbench .report-status-kpi-row .metric-card { + height: auto; + min-height: 84px; + } + + .report-workbench .report-status-kpi-row .metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .report-workbench .report-workspace-grid, + .report-workbench .report-main-grid, + .report-workbench .report-review-grid { + height: auto; + min-height: 0; + grid-template-columns: minmax(0, 1fr) !important; + overflow: visible; + } + + .report-workbench .report-workspace-right, + .report-main-grid > .report-workspace-right, + .report-review-grid > .report-workspace-right { + height: auto; + min-height: 0; + grid-column: auto; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto; + overflow: visible; + } + + .report-workbench .report-list-card, + .report-workbench .report-preview-card, + .report-workbench .report-review-preview, + .report-workbench .report-review-preview-shell { + height: auto; + min-height: 320px; + overflow: hidden; + } + + .report-workbench .report-list-scroll { + max-height: 420px; + } + + .report-workbench .report-preview-split, + .report-workbench .report-preview-card .report-preview-split { + height: auto; + min-height: 520px; + max-height: none; + flex-basis: auto; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(250px, auto)); + } + + .report-workbench .report-review-info-grid { + height: auto; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto; + } +} + +@media (max-width: 560px) { + .report-workbench .report-status-kpi-row .metric-grid { + grid-template-columns: minmax(0, 1fr); + } + + .report-workbench .report-list-head, + .report-workbench .report-list-item { + grid-template-columns: minmax(0, 1fr) 64px; + } + + .report-workbench .report-list-head > :nth-child(2), + .report-workbench .report-list-item > :nth-child(2) { + display: none; + } +} diff --git a/frontend/src/pages/task/TaskCenterPage.tsx b/frontend/src/pages/task/TaskCenterPage.tsx index 67b7573..5a7d8d8 100644 --- a/frontend/src/pages/task/TaskCenterPage.tsx +++ b/frontend/src/pages/task/TaskCenterPage.tsx @@ -38,13 +38,8 @@ const taskOptions = [ { value: 'embedding_refresh', label: 'Embedding 刷新' }, { value: 'report_generate', label: '报告生成' }, { value: 'sync_core_data', label: '数据同步' }, - { value: 'data_clean', label: '数据清洗' }, { value: 'price_predict', label: '电价预测' }, - { value: 'load_predict', label: '负荷预测' }, - { value: 'strategy_gen', label: '策略生成' }, { value: 'report_daily', label: '收益分析' }, - { value: 'monitor_rt', label: '异常监测' }, - { value: 'model_train', label: '模型训练' }, { value: 'fast_forecast', label: '快速预测' }, { value: 'health_check', label: '健康检查' } ]; @@ -186,8 +181,8 @@ export function TaskCenterPage(_props: PageProps) { const [taskData, setTaskData] = useState({ tasks: [], metrics: [], health: {}, retryQueue: [], recentLogs: [], queueRows: [], trendRows: [] }); const [loading, setLoading] = useState(true); const { canPerformAction } = useAuth(); - const canRunTask = canPerformAction('task:manage'); - const canManageSchedules = canPerformAction('task:manage'); + const canRunTask = canPerformAction('task:run'); + const canManageSchedules = canPerformAction('task:run'); const canDiagnoseTasks = canPerformAction('task:diagnostics'); const [createOpen, setCreateOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false); @@ -235,6 +230,10 @@ export function TaskCenterPage(_props: PageProps) { }, [loadData]); async function runTask(kind = selectedTaskKind) { + if (!workerReady) { + message.error('当前没有可接收任务的 Worker,已禁止创建新任务。'); + return; + } const safeKind = kind === 'all' ? 'health_check' : kind; const res = await api.taskStart({ task_type: safeKind, @@ -252,6 +251,10 @@ export function TaskCenterPage(_props: PageProps) { message.warning('缺少 task_id'); return; } + if (!workerReady) { + message.error('当前没有可接收任务的 Worker,已禁止重试。'); + return; + } const res = await api.taskRetry(taskId); message.success(`重试任务已创建:${res.task_id || taskId}`); await loadData(); @@ -330,6 +333,7 @@ export function TaskCenterPage(_props: PageProps) { const rawTasks = Array.isArray(taskData.tasks) ? taskData.tasks : []; const tasks = useMemo(() => rawTasks, [rawTasks]); const health = taskData.health || {}; + const workerReady = health.dispatch_ready === true; const retryRows = Array.isArray(taskData.retryQueue) ? taskData.retryQueue : []; const queueRows = Array.isArray(taskData.queueRows) ? taskData.queueRows : []; const recentLogRows = Array.isArray(taskData.recentLogs) ? taskData.recentLogs : []; @@ -398,10 +402,10 @@ export function TaskCenterPage(_props: PageProps) { }), [queueRows]); const healthCards = [ - { label: 'execution_mode', value: health.execution_mode || 'distributed', tone: 'blue', icon: }, - { label: 'Redis', value: health.redis?.ok ? '运行中' : '待确认', tone: 'green', icon: }, - { label: 'Celery', value: health.celery?.ok || health.celery_available ? '运行中' : '待确认', tone: 'green', icon: }, - { label: 'active_workers', value: `${(health.active_workers || []).length || 0} / 20`, tone: 'green', icon: }, + { label: 'execution_mode', value: health.execution_mode || '未配置', tone: 'blue', icon: }, + { label: 'Redis', value: health.redis?.ok ? '运行中' : '不可用', tone: health.redis?.ok ? 'green' : 'red', icon: }, + { label: 'Celery', value: health.celery?.ok || health.celery_available ? '运行中' : '不可用', tone: health.celery?.ok || health.celery_available ? 'green' : 'red', icon: }, + { label: 'active_workers', value: String((health.active_workers || []).length || 0), tone: workerReady ? 'green' : 'red', icon: }, { label: 'running', value: runningCount, tone: 'blue', icon: }, { label: 'pending', value: pendingCount, tone: 'blue', icon: }, { label: 'failed', value: failedCount, tone: 'red', icon: }, @@ -436,7 +440,7 @@ export function TaskCenterPage(_props: PageProps) { menu={{ items: [ { key: 'cancel', label: '取消任务', disabled: !canCancelTask(record.status) }, - { key: 'retry', label: '立即重试', disabled: !canRetryTask(record) } + { key: 'retry', label: '立即重试', disabled: !workerReady || !canRetryTask(record) } ], onClick: ({ key }) => { if (key === 'cancel') cancelTask(record.task_id); @@ -455,7 +459,7 @@ export function TaskCenterPage(_props: PageProps) { const logColumns = [ { title: '时间', width: 76, render: (_: any, record: any) => shortTime(record.updated_at || record.created_at) }, { title: '任务名称', render: (_: any, record: any) => taskName(record) }, - { title: 'Worker', width: 86, dataIndex: 'worker_id', render: (value: string) => value || 'worker-01' }, + { title: 'Worker', width: 86, dataIndex: 'worker_id', render: (value: string) => value || '未分配' }, { title: 'Celery Task ID', width: 150, dataIndex: 'celery_task_id', render: shortId }, { title: '消息', render: (_: any, record: any) => record.error_message || record.message || (record.status === 'success' ? '任务执行完成' : statusText(record.status)) } ]; @@ -465,7 +469,7 @@ export function TaskCenterPage(_props: PageProps) { { title: '任务名称', render: (_: any, record: any) => taskName(record) }, { title: '原因', render: (_: any, record: any) => record.error_message || statusText(record.status) }, { title: '处理建议', render: (_: any, record: any) => failureAdvice(record) }, - ...(canManageSchedules ? [{ title: '操作', width: 98, render: (_: any, record: any) => }] : []) + ...(canManageSchedules ? [{ title: '操作', width: 98, render: (_: any, record: any) => }] : []) ]; const queueColumns = [ @@ -498,7 +502,7 @@ export function TaskCenterPage(_props: PageProps) { label: '启动任务', icon: , type: 'primary', - hidden: !canRunTask, + hidden: !canRunTask || !workerReady, disabled: selectedTaskKind === 'all', disabledReason: selectedTaskKind === 'all' ? '请先选择具体任务类型' : undefined, onClick: () => runTask() diff --git a/frontend/src/security/permissions.ts b/frontend/src/security/permissions.ts index a5b0d70..983b49f 100644 --- a/frontend/src/security/permissions.ts +++ b/frontend/src/security/permissions.ts @@ -37,7 +37,7 @@ export const childPermissions: Record = { 'report-daily': ['report:read'], 'report-weekly': ['report:read'], 'report-review': ['report:read', 'report:review'], - 'report-publish': ['report:read', 'report:review'], + 'report-publish': ['report:read', 'report:publish'], 'model-active': ['model:read'], 'model-candidate': ['model:read'], 'model-error': ['model:read'], @@ -62,6 +62,9 @@ export const actionPermissions: Record = { 'data.export': 'data:export', 'strategy.generate': 'strategy:generate', 'report.generate': 'report:generate', + 'report.approve': 'report:review', + 'report.reject': 'report:review', + 'report.publish': 'report:publish', 'settings.write': 'settings:write', 'assistant.use': 'assistant:use', 'assistant.export': 'assistant:export' diff --git a/frontend/src/services/assistantApi.ts b/frontend/src/services/assistantApi.ts index 1ff4280..44b4973 100644 --- a/frontend/src/services/assistantApi.ts +++ b/frontend/src/services/assistantApi.ts @@ -249,8 +249,8 @@ function normalizeAttachmentRecord(payload: any, file?: File): AssistantAttachme }; } -export async function getAssistantAttachment(attachmentId: string, sessionId?: string) { - const query = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : ''; +export async function getAssistantAttachment(attachmentId: string, sessionId: string) { + const query = `?session_id=${encodeURIComponent(sessionId)}`; const response = await fetch(downloadUrl(`/api/ai/attachments/${encodeURIComponent(attachmentId)}${query}`), { headers: authHeaders(false) }); @@ -261,8 +261,9 @@ export async function getAssistantAttachment(attachmentId: string, sessionId?: s return normalizeAttachmentRecord(await response.json()); } -export async function deleteAssistantAttachment(attachmentId: string) { - const response = await fetch(downloadUrl(`/api/ai/attachments/${encodeURIComponent(attachmentId)}`), { +export async function deleteAssistantAttachment(attachmentId: string, sessionId: string) { + const query = `?session_id=${encodeURIComponent(sessionId)}`; + const response = await fetch(downloadUrl(`/api/ai/attachments/${encodeURIComponent(attachmentId)}${query}`), { method: 'DELETE', headers: authHeaders(false) }); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ceb829c..bea4ce7 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -9011,7 +9011,7 @@ a { display: block; margin-bottom: 2px; color: var(--color-muted); - font-size: 10px; + font-size: 12px; line-height: 1.25; } @@ -13470,3 +13470,271 @@ a { overscroll-behavior: contain; } } + +/* T008 responsive shell closure: the fixed desktop navigation becomes an + explicit off-canvas drawer on narrow screens, while page content keeps the + full viewport width. */ +.mobile-nav-toggle, +.mobile-sidebar-backdrop { + display: none !important; +} + +@media (max-width: 900px) { + .app-shell { + --page-padding: 12px; + } + + .content-shell:has(.assistant-workspace-page) > .page-container > .page-header-area { + margin-inline: -12px; + } + + .app-shell .assistant-title-actions { + position: static; + top: auto; + z-index: auto; + height: auto; + min-height: 36px; + flex: 0 0 auto; + justify-content: flex-end; + margin: 8px 0; + } + + .app-shell .assistant-action-row { + min-height: auto; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: auto; + } + + .app-shell .assistant-action-row .ant-btn { + height: auto; + min-height: 38px; + white-space: normal; + } + + .app-shell > .header-bar, + .app-shell.data-workspace-shell > .header-bar.data-workspace-header, + .app-shell.app-shell--report > .header-bar { + gap: 8px; + padding-inline: 10px !important; + } + + .mobile-nav-toggle { + display: inline-flex !important; + flex: 0 0 36px; + } + + .header-brand { + min-width: 0; + flex: 1 1 auto; + gap: 8px; + } + + .header-brand-mark { + width: 30px; + height: 30px; + flex-basis: 30px; + border-radius: 8px; + } + + .header-brand strong { + font-size: 15px; + } + + .header-context, + .data-workspace-context { + display: none !important; + } + + .header-actions { + gap: 2px !important; + margin-inline-start: auto; + } + + .header-actions .ant-space-item { + flex: 0 0 auto; + } + + .header-actions .user-area { + width: 36px; + min-width: 36px; + padding-inline: 0; + } + + .header-actions .user-area strong, + .header-actions .user-area .ant-tag, + .header-actions .user-area .anticon-down { + display: none; + } + + .app-shell .app-body > .sidebar-shell, + .app-shell .app-body > .sidebar-shell.collapsed { + width: min(82vw, 288px); + height: calc(100dvh - var(--header-height)); + flex-basis: min(82vw, 288px); + visibility: visible; + transform: translateX(0); + box-shadow: 12px 0 30px rgba(15, 23, 42, 0.2); + transition: transform 0.2s ease, visibility 0.2s ease; + } + + .app-shell .app-body > .sidebar-shell.collapsed { + visibility: hidden; + transform: translateX(-105%); + } + + .sidebar-shell.compact-navigation.collapsed .collapse-entry { + display: none; + } + + .app-shell .app-body > .main-shell, + .app-shell .app-body > .sidebar-shell.collapsed + .mobile-sidebar-backdrop + .main-shell, + .app-shell .app-body > .sidebar-shell.collapsed + .main-shell { + width: 100%; + margin-inline-start: 0; + } + + .mobile-sidebar-backdrop { + position: fixed; + top: var(--header-height); + right: 0; + bottom: 0; + left: 0; + z-index: calc(var(--z-sidebar) - 1); + display: block !important; + border: 0; + background: rgba(15, 23, 42, 0.34); + } + + .app-shell .main-shell > .content-shell, + .app-shell.data-workspace-shell .main-shell.data-workspace-main > .content-shell, + .app-shell.app-shell--report .main-shell > .content-shell { + padding-inline: var(--page-padding) !important; + } + + .page-heading-unified, + .page-heading-unified .page-heading-title-row, + .page-heading-unified .page-heading-utility, + .page-heading-unified .page-heading-context, + .page-heading-unified .page-heading-filters, + .page-heading-unified .page-heading-actions, + .filter-bar, + .filter-bar-fields, + .filter-bar-actions, + .settings-tabs-row, + .settings-tab-actions { + min-width: 0; + flex-wrap: wrap; + } + + .page-heading-unified .page-heading-utility, + .page-heading-unified .page-heading-context, + .page-heading-unified .page-heading-filters, + .page-heading-unified .page-heading-actions, + .settings-tabs-row, + .settings-tab-actions { + width: 100%; + } + + .settings-tabs-row { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .task-workbench-page { + height: auto; + min-height: calc(100dvh - var(--header-height) - 24px); + } + + .task-titlebar, + .task-summary-grid, + .task-body-grid, + .task-main-grid, + .settings-status-grid, + .settings-bottom-grid, + .settings-user-layout, + .settings-api-grid { + grid-template-columns: minmax(0, 1fr); + } + + .task-filter-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .task-filter-actions .ant-select, + .task-filter-actions .ant-picker-range, + .task-filter-actions .ant-input-affix-wrapper, + .task-filter-actions > .ant-input { + width: min(100%, 260px); + } + + .task-list-card, + .task-trend-card, + .task-queue-card { + min-height: 280px; + flex-basis: auto; + } + + .task-card-head { + height: auto; + align-items: stretch; + flex-direction: column; + padding-block: 9px; + } + + .task-card-head > div { + width: 100%; + flex-wrap: wrap; + gap: 8px; + } + + .task-card-head > .ant-space { + display: grid; + width: 100%; + grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); + gap: 8px !important; + } + + .task-card-head > .ant-space .ant-btn { + width: 100%; + } +} + +@media (max-width: 560px) { + .header-help-action, + .header-refresh-action { + display: none !important; + } + + .header-brand strong { + max-width: 156px; + font-size: 14px; + } + + .task-kpi-grid, + .task-health-card .task-health-grid, + .settings-param-grid, + .settings-security-grid, + .settings-api-grid { + grid-template-columns: minmax(0, 1fr); + } + + .task-filter-actions > span, + .task-filter-actions .ant-select, + .task-filter-actions .ant-picker-range, + .task-filter-actions .ant-input-affix-wrapper, + .task-filter-actions > .ant-input, + .task-filter-actions .ant-btn { + width: 100%; + } + + .settings-status-row { + grid-template-columns: 34px minmax(0, 1fr) auto; + } + + .settings-status-row .ant-btn-link { + grid-column: 2 / -1; + justify-self: start; + } +} diff --git a/frontend/tests/reportCenterPresentation.test.mjs b/frontend/tests/reportCenterPresentation.test.mjs index 571ed25..1ea30d3 100644 --- a/frontend/tests/reportCenterPresentation.test.mjs +++ b/frontend/tests/reportCenterPresentation.test.mjs @@ -27,7 +27,7 @@ test('报告中心业务内容不展示指定的过期追溯详情', () => { test('报告路由使用独立内容工作台并完整保留左侧目录', () => { assert.equal(layoutSource.includes("route === 'report' ? 'app-shell--report' : ''"), true); - assert.equal(layoutSource.includes(' { "'forbidden'", 'onGenerate={generateReport}', 'onDownload={downloadReport}', - "review('approve')", - "review('reject')", - "review('publish')" + "confirmReview('approve')", + "confirmReview('reject')", + "confirmReview('publish')" ]) { assert.equal(source.includes(marker), true, `应保留状态或真实功能标记:${marker}`); } diff --git a/frontend/tests/uiShellContracts.test.mjs b/frontend/tests/uiShellContracts.test.mjs index a7e09cd..47c1da5 100644 --- a/frontend/tests/uiShellContracts.test.mjs +++ b/frontend/tests/uiShellContracts.test.mjs @@ -63,6 +63,25 @@ test('Phase 2 AppShell fixes navigation and delegates scrolling to the main cont assert.match(styles, /@media \(max-width: 1366px\), \(max-height: 768px\)/); }); +test('T008 compact navigation releases page width and report center collapses to one column', async () => { + const [app, layout, header, sidebar, styles, reportStyles] = await Promise.all([ + read('../src/app/App.tsx'), + read('../src/layout/BasicLayout.tsx'), + read('../src/layout/HeaderBar.tsx'), + read('../src/layout/Sidebar.tsx'), + read('../src/styles.css'), + read('../src/pages/report/report-center-layout.css') + ]); + assert.match(app, /compactNavigationQuery = '\(max-width: 900px\)'/); + assert.match(app, /if \(compactNavigation\) setCollapsed\(true\)/); + assert.match(layout, /mobile-sidebar-backdrop/); + assert.match(header, /aria-controls="primary-navigation"/); + assert.match(sidebar, /id="primary-navigation"/); + assert.match(styles, /@media \(max-width: 900px\)[\s\S]*?sidebar-shell\.collapsed[\s\S]*?translateX\(-105%\)/); + assert.match(styles, /@media \(max-width: 900px\)[\s\S]*?\.main-shell[\s\S]*?margin-inline-start:\s*0/); + assert.match(reportStyles, /@media \(max-width: 900px\)[\s\S]*?report-review-grid[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) !important/); +}); + test('AttachmentComposer covers select, drag/drop, clipboard and explicit browser fallback', async () => { const source = await read('../src/features/globalAssistant/AttachmentComposer.tsx'); assert.match(source, /onDrop/); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6a4bcd1..65b8e41 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,10 +1,80 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || process.env.VITE_API_BASE_URL || 'http://127.0.0.1:8000'; +const frontendDir = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(frontendDir, '..'); + +function git(...args: string[]) { + try { + return execFileSync('git', ['-C', projectRoot, ...args], { encoding: 'utf8' }).trim() || 'unknown'; + } catch { + return 'unknown'; + } +} + +function gitStatusIsDirty() { + try { + return execFileSync( + 'git', + ['-C', projectRoot, 'status', '--porcelain', '--untracked-files=no'], + { encoding: 'utf8' } + ).trim() !== ''; + } catch { + return true; + } +} + +const rawVersion = readFileSync(resolve(projectRoot, 'VERSION'), 'utf8').trim(); +const gitSha = process.env.APP_GIT_SHA?.trim() || git('rev-parse', 'HEAD'); +const gitBranch = process.env.APP_GIT_BRANCH?.trim() || git('branch', '--show-current'); +const dirtyOverride = process.env.APP_WORKTREE_DIRTY?.trim().toLowerCase(); +const dirty = dirtyOverride + ? ['1', 'true', 'yes', 'on'].includes(dirtyOverride) + : gitStatusIsDirty(); +const sourceFingerprint = createHash('sha256') + .update(resolve(projectRoot).toLowerCase(), 'utf8') + .digest('hex') + .slice(0, 16); +const runtimeIdentity = { + version: rawVersion.startsWith('v') ? rawVersion : `v${rawVersion}`, + git_sha: gitSha, + git_branch: gitBranch, + build_id: process.env.APP_BUILD_ID?.trim() || `${rawVersion}+${gitSha.slice(0, 12)}`, + working_tree_dirty: dirty, + source_fingerprint: sourceFingerprint +}; + +function runtimeIdentityPlugin() { + return { + name: 'runtime-identity', + configureServer(server: any) { + server.middlewares.use('/runtime-identity.json', (_request: any, response: any) => { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json; charset=utf-8'); + response.end(JSON.stringify(runtimeIdentity)); + }); + }, + generateBundle(this: any) { + this.emitFile({ + type: 'asset', + fileName: 'runtime-identity.json', + source: JSON.stringify(runtimeIdentity, null, 2) + }); + } + }; +} export default defineConfig({ - plugins: [react()], + plugins: [react(), runtimeIdentityPlugin()], + define: { + __APP_RUNTIME_IDENTITY__: JSON.stringify(runtimeIdentity) + }, build: { chunkSizeWarningLimit: 1400, rollupOptions: { diff --git a/main_daily_run.py b/main_daily_run.py index ed7d882..f8e3cdc 100644 --- a/main_daily_run.py +++ b/main_daily_run.py @@ -2,7 +2,9 @@ import argparse import os +import re import time +from pathlib import Path from openpyxl import load_workbook @@ -31,6 +33,7 @@ "16_滚动回测结果.xlsx", "19_业务统计摘要.xlsx", ] +SAFE_RUN_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{1,128}$") def _xlsx_data_rows(path) -> int: @@ -108,6 +111,8 @@ def validate_skip_prediction_inputs(paths, log) -> None: def build_sequence(args: argparse.Namespace) -> list[str]: + if args.retrain_model: + return ["01_run_prediction.py"] if args.skip_prediction: return [item for item in SCRIPT_SEQUENCE if item != "01_run_prediction.py"] sequence = SCRIPT_SEQUENCE.copy() @@ -118,6 +123,20 @@ def build_sequence(args: argparse.Namespace) -> list[str]: return sequence +def configure_retrain_workspace(config: dict, run_id: str) -> Path: + if not SAFE_RUN_ID_PATTERN.fullmatch(run_id): + raise SystemExit("显式重训 run_id 含非法字符,已拒绝创建运行目录") + runtime_root = Path(__file__).resolve().parent / ".codex_tmp" / "model_training" / run_id + if runtime_root.exists(): + raise SystemExit(f"显式重训运行目录已存在,拒绝覆盖:{runtime_root}") + result_dir = runtime_root / "results" + paths_config = config.setdefault("paths", {}) + paths_config["data_dir"] = str(runtime_root / "input") + paths_config["result_dir"] = str(result_dir) + paths_config["result_table_dir"] = str(result_dir / "结果表") + return runtime_root + + def main() -> None: parser = argparse.ArgumentParser(description="电价预测 + AI 日报自动化总控") parser.add_argument("--skip-prediction", action="store_true", help="跳过预测,只基于现有结果生成 AI 报告") @@ -128,18 +147,22 @@ def main() -> None: parser.add_argument("--model-auto-optimize", action="store_true", help="执行误差记忆、退化判断和必要时自动重训") args = parser.parse_args() + run_id = os.environ.get("PIPELINE_RUN_ID", "").strip() or now_compact() + run_mode = build_run_mode(args) + run_started_at = now_text() config = load_config() + retrain_runtime_root = configure_retrain_workspace(config, run_id) if args.retrain_model else None paths = get_pipeline_paths(config) log, log_path = setup_run_logger(paths.log_dir, "main_daily_run") - run_id = now_compact() - run_mode = build_run_mode(args) - run_started_at = now_text() env = os.environ.copy() env["PYTHONUTF8"] = "1" env["PIPELINE_RUN_ID"] = run_id env["PIPELINE_RUN_MODE"] = run_mode env["PIPELINE_RUN_STARTED_AT"] = run_started_at + if retrain_runtime_root is not None: + env["PIPELINE_DATA_DIR"] = str(paths.data_dir) + env["PIPELINE_RESULT_DIR"] = str(paths.result_dir) if args.fast_forecast: env["PIPELINE_FORECAST_MODE"] = "fast" @@ -156,9 +179,13 @@ def main() -> None: if args.skip_prediction: validate_skip_prediction_inputs(paths, log) - db_ok = disable_database_if_unavailable(config, log=log) - if not db_ok: - log("数据库同步、模型注册和追踪落库将被跳过。") + if args.retrain_model: + config.setdefault("database", {})["enabled"] = False + log("显式重训已禁用旧 MySQL 同步;输入读取与 Candidate 登记使用独立受限 PostgreSQL 身份。") + else: + db_ok = disable_database_if_unavailable(config, log=log) + if not db_ok: + log("数据库同步、模型注册和追踪落库将被跳过。") total_start = time.perf_counter() save_pipeline_event(config, "main_daily_run", "started", f"总控开始,模式:{run_mode}") diff --git a/migrations/versions/0024_runtime_role_permissions_v2.py b/migrations/versions/0024_runtime_role_permissions_v2.py new file mode 100644 index 0000000..b0fce38 --- /dev/null +++ b/migrations/versions/0024_runtime_role_permissions_v2.py @@ -0,0 +1,121 @@ +"""align persisted built-in roles with the runtime permission ceilings + +Revision ID: 0024_role_permissions_v2 +Revises: 0023_model_center_facts +Create Date: 2026-08-30 +""" +from __future__ import annotations + +import json + +import sqlalchemy as sa +from alembic import op + + +revision = "0024_role_permissions_v2" +down_revision = "0023_model_center_facts" +branch_labels = None +depends_on = None + + +LEGACY_DEFAULTS = { + "analyst": [ + "dashboard:read", "forecast:read", "forecast:run", "data:read", + "data:sync", "task:read", "task:run", "assistant:use", + "knowledge:read", "knowledge:write", "report:read", + "report:generate", "report:review", "model:read", + ], + "developer": [ + "dashboard:read", "forecast:read", "data:read", "task:read", + "assistant:use", "assistant:debug", "knowledge:read", "report:read", + "model:read", "trace:read", "security:read", + ], + "viewer": [ + "dashboard:read", "forecast:read", "data:read", "assistant:use", + "knowledge:read", "report:read", "model:read", "task:read", + ], + "reviewer": ["strategy:read", "strategy:review"], + "operator": [ + "dashboard:read", "forecast:read", "forecast:run", "data:read", + "task:read", "task:run", "assistant:use", "knowledge:read", + "report:read", "report:generate", + ], +} + +# A previous controlled remediation expanded reviewer from the original +# strategy-only default to this exact report/strategy subset. It is a known +# product default, not an arbitrary tenant customization, so it is safe to +# advance while all other non-matching role configurations remain untouched. +INTERMEDIATE_DEFAULTS = { + "reviewer": [ + "report:download", "report:read", "report:review", + "strategy:read", "strategy:review", + ], +} + +RUNTIME_DEFAULTS = { + "analyst": [ + "assistant:export", "assistant:use", "auth:self", "dashboard:read", + "data:export", "data:query", "data:read", "data:sync", + "forecast:read", "forecast:run", "knowledge:export", + "knowledge:read", "model:read", "report:download", + "report:generate", "report:read", "strategy:generate", + "strategy:read", "strategy:submit", "task:read", + ], + "developer": [ + "assistant:debug", "assistant:export", "assistant:use", "audit:read", + "auth:self", "dashboard:read", "data:export", "data:query", + "data:read", "forecast:read", "knowledge:diagnose", + "knowledge:export", "knowledge:read", "model:export", "model:read", + "report:download", "report:read", "security:read", "settings:read", + "strategy:read", "system:diagnostics", "task:diagnostics", + "task:read", "trace:read", "user:read", + ], + "viewer": [ + "auth:self", "dashboard:read", "forecast:read", "knowledge:read", + "report:read", "strategy:read", + ], + "reviewer": [ + "auth:self", "dashboard:read", "forecast:read", "knowledge:publish", + "knowledge:read", "knowledge:write", "report:download", + "report:read", "report:review", "strategy:read", "strategy:review", + ], + "operator": [ + "assistant:export", "assistant:use", "auth:self", "dashboard:read", + "data:export", "data:query", "data:read", "data:sync", + "forecast:read", "forecast:run", "knowledge:export", + "knowledge:read", "model:read", "report:download", + "report:generate", "report:read", "strategy:generate", + "strategy:read", "strategy:submit", "task:read", + ], +} + + +def _replace_defaults(source: dict[str, list[str]], target: dict[str, list[str]]) -> None: + connection = op.get_bind() + statement = sa.text( + """ + UPDATE roles + SET permissions_json = CAST(:target AS jsonb), updated_at = CURRENT_TIMESTAMP + WHERE role_id = :role_id + AND permissions_json = CAST(:source AS jsonb) + """ + ) + for role_id, source_permissions in source.items(): + connection.execute( + statement, + { + "role_id": role_id, + "source": json.dumps(source_permissions, separators=(",", ":")), + "target": json.dumps(target[role_id], separators=(",", ":")), + }, + ) + + +def upgrade() -> None: + _replace_defaults(LEGACY_DEFAULTS, RUNTIME_DEFAULTS) + _replace_defaults(INTERMEDIATE_DEFAULTS, RUNTIME_DEFAULTS) + + +def downgrade() -> None: + _replace_defaults(RUNTIME_DEFAULTS, LEGACY_DEFAULTS) diff --git a/model_ops/model_registry.py b/model_ops/model_registry.py index 8d76100..6b0345a 100644 --- a/model_ops/model_registry.py +++ b/model_ops/model_registry.py @@ -1,13 +1,64 @@ from __future__ import annotations -import json import hashlib +import json +import os from pathlib import Path from typing import Any import pandas as pd +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine, make_url + from backend.app.services.model_fact_service import DEFAULT_MODEL_DOMAIN, ModelFactError, ModelFactService -from database_utils import create_database_engine + + +MODEL_TRAINING_DATABASE_URL_ENV = "MODEL_TRAINING_DATABASE_URL" +MODEL_TRAINING_DATABASE_USER = "beta10d_forecast_login" +MODEL_TRAINING_ALLOWED_HOSTS = frozenset({"127.0.0.1", "localhost", "postgres"}) + + +def create_model_training_engine() -> Engine: + """创建只用于训练输入读取与 Candidate 登记的受限 PostgreSQL 连接。""" + + raw_url = os.environ.get(MODEL_TRAINING_DATABASE_URL_ENV, "").strip() + if not raw_url: + raise ModelFactError("显式重训缺少独立 MODEL_TRAINING_DATABASE_URL") + try: + parsed = make_url(raw_url) + except Exception as exc: + raise ModelFactError("MODEL_TRAINING_DATABASE_URL 格式无效") from exc + if not parsed.drivername.startswith("postgresql"): + raise ModelFactError("显式重训只允许 PostgreSQL") + if str(parsed.host or "").lower() not in MODEL_TRAINING_ALLOWED_HOSTS: + raise ModelFactError("显式重训数据库目标不在本机/受控 Compose 范围") + if parsed.username != MODEL_TRAINING_DATABASE_USER: + raise ModelFactError("显式重训必须使用 beta10d_forecast_login 受限身份") + + try: + engine = create_engine(raw_url, pool_pre_ping=True, future=True) + with engine.connect() as conn: + identity = conn.execute( + text( + "SELECT current_user, session_user, r.rolsuper, r.rolcreatedb, " + "r.rolcreaterole, r.rolreplication, r.rolbypassrls " + "FROM pg_roles r WHERE r.rolname = current_user" + ) + ).mappings().one() + except Exception as exc: + raise ModelFactError("显式重训 PostgreSQL 连接或身份核验失败") from exc + privileged = any( + bool(identity[name]) + for name in ("rolsuper", "rolcreatedb", "rolcreaterole", "rolreplication", "rolbypassrls") + ) + if ( + identity["current_user"] != MODEL_TRAINING_DATABASE_USER + or identity["session_user"] != MODEL_TRAINING_DATABASE_USER + or privileged + ): + engine.dispose() + raise ModelFactError("显式重训 PostgreSQL 身份不满足最小权限门禁") + return engine def load_artifact_manifest(artifact_dir: str | Path) -> dict[str, Any]: @@ -17,6 +68,33 @@ def load_artifact_manifest(artifact_dir: str | Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) +def write_artifact_hash_manifest(artifact_dir: str | Path) -> str: + root = Path(artifact_dir) + hash_manifest_path = root / "sha256_manifest.json" + entries: dict[str, str] = {} + for path in sorted(root.rglob("*"), key=lambda item: item.as_posix()): + if not path.is_file() or path == hash_manifest_path: + continue + entries[path.relative_to(root).as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest() + if not entries: + raise ModelFactError("模型 artifact 目录没有可校验文件") + artifact_hash = hashlib.sha256( + json.dumps(entries, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + payload = { + "algorithm": "SHA-256", + "artifact_hash": artifact_hash, + "files": entries, + } + serialized = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + if hash_manifest_path.exists(): + if hash_manifest_path.read_text(encoding="utf-8") != serialized: + raise ModelFactError("已有 sha256_manifest.json 与当前 artifact 不一致") + else: + hash_manifest_path.write_text(serialized, encoding="utf-8") + return artifact_hash + + def _safe_float(value: Any) -> float | None: try: if value is None or pd.isna(value): @@ -77,6 +155,10 @@ def register_model_artifact(config: dict[str, Any], artifact_dir: str | Path, st training = json.loads(training_path.read_text(encoding="utf-8")) if training_path.exists() else {} input_schema = json.loads(schema_path.read_text(encoding="utf-8")) if schema_path.exists() else {} model_version = manifest["model_version"] + artifact_hash = write_artifact_hash_manifest(artifact_dir) + declared_artifact_hash = str(manifest.get("artifact_hash") or "").strip() + if declared_artifact_hash and declared_artifact_hash != artifact_hash: + raise ModelFactError("manifest 声明的 artifact_hash 与静态文件校验结果不一致") model_role = "blended" metric_row = _preferred_metric_row(metrics) peak_row = _preferred_peak_row(metrics) @@ -105,25 +187,29 @@ def register_model_artifact(config: dict[str, Any], artifact_dir: str | Path, st if not domain or not target_name: raise ModelFactError("artifact 元数据缺少 domain 或 target_name") schema_hash = hashlib.sha256(schema_path.read_bytes()).hexdigest() if schema_path.exists() else None - ModelFactService(create_database_engine(config)).register_candidate( - { - "model_id": str(manifest.get("model_id") or model_version), - "model_version": model_version, - "domain": domain, - "target_name": target_name, - "model_role": model_role, - "artifact_id": str(manifest.get("artifact_id") or Path(artifact_dir).name), - "artifact_path": str(Path(artifact_dir).resolve()), - "artifact_hash": manifest.get("artifact_hash"), - "feature_version": training.get("feature_version") or metrics.get("feature_version") or input_schema.get("feature_version"), - "schema_hash": manifest.get("schema_hash") or schema_hash, - "source_type": "training", - "train_start_date": train_start_date, - "train_end_date": train_end_date, - "test_mae": _safe_float(metric_row.get("MAE")), - "test_rmse": _safe_float(metric_row.get("RMSE")), - "peak_rmse": _safe_float(_find_key(peak_row, ["高峰", "RMSE"])), - "spike_rmse": _safe_float(_find_key(peak_row, ["尖峰", "RMSE"])), - } - ) + engine = create_model_training_engine() + try: + ModelFactService(engine).register_candidate( + { + "model_id": str(manifest.get("model_id") or model_version), + "model_version": model_version, + "domain": domain, + "target_name": target_name, + "model_role": model_role, + "artifact_id": str(manifest.get("artifact_id") or f"artifact_{artifact_hash[:24]}"), + "artifact_path": str(Path(artifact_dir).resolve()), + "artifact_hash": artifact_hash, + "feature_version": training.get("feature_version") or metrics.get("feature_version") or input_schema.get("feature_version"), + "schema_hash": manifest.get("schema_hash") or schema_hash, + "source_type": "training", + "train_start_date": train_start_date, + "train_end_date": train_end_date, + "test_mae": _safe_float(metric_row.get("MAE")), + "test_rmse": _safe_float(metric_row.get("RMSE")), + "peak_rmse": _safe_float(_find_key(peak_row, ["高峰", "RMSE"])), + "spike_rmse": _safe_float(_find_key(peak_row, ["尖峰", "RMSE"])), + } + ) + finally: + engine.dispose() return model_version diff --git a/scripts/day3_generate_permission_matrix.py b/scripts/day3_generate_permission_matrix.py index 017ab2e..62c2c4b 100644 --- a/scripts/day3_generate_permission_matrix.py +++ b/scripts/day3_generate_permission_matrix.py @@ -132,7 +132,9 @@ def required_permission(method: str, path: str) -> str: if path.startswith("/api/reports"): if path.endswith("/download"): return "report:download" - if path.endswith("/reviews") or path.endswith(("/approve", "/reject", "/publish")): + if path.endswith("/publish"): + return "report:publish" + if path.endswith("/reviews") or path.endswith(("/approve", "/reject")): return "report:review" if method != "GET": return "report:generate" @@ -212,6 +214,7 @@ def _risk(permission: str, anonymous: bool, write: bool) -> str: "model:manage", "model:read", "report:download", + "report:publish", "report:review", "security:read", "settings:read", diff --git a/scripts/day3_test_database_guard.py b/scripts/day3_test_database_guard.py index 1c70d91..54e8e6f 100644 --- a/scripts/day3_test_database_guard.py +++ b/scripts/day3_test_database_guard.py @@ -27,7 +27,7 @@ EXPECTED_USER = "postgres" EXPECTED_HOSTS = {"localhost", "127.0.0.1", "::1"} EXPECTED_PORT = 5432 -EXPECTED_ALEMBIC_HEAD = "0023_model_center_facts" +EXPECTED_ALEMBIC_HEAD = "0024_role_permissions_v2" EXPECTED_ALEMBIC_HEAD_ENV = "BETA10D_TEST_EXPECTED_ALEMBIC_HEAD" ISOLATION_FLAG = "BETA10D_TEST_ISOLATION_ACTIVE" SCHEMA_ENV = "BETA10D_TEST_SCHEMA" diff --git a/scripts/day5_memory_worker_runtime.py b/scripts/day5_memory_worker_runtime.py index 05ab719..94eb40c 100644 --- a/scripts/day5_memory_worker_runtime.py +++ b/scripts/day5_memory_worker_runtime.py @@ -15,6 +15,7 @@ sys.path.insert(0, str(ROOT)) from scripts.phase4_precheck_runtime import _pid_active, _terminate_pid, configure_runtime +from backend.app.core.redaction import redact_text def paths() -> dict[str, Path]: @@ -52,32 +53,37 @@ def status() -> dict: } -def run() -> int: +def run_once() -> dict: from sqlalchemy import text from backend.app.ai_assistant.memory.enterprise_memory import _engine from backend.app.ai_assistant.memory.lifecycle import process_outbox_batch value = paths() value["runtime"].mkdir(parents=True, exist_ok=True) + report = {"ok": False, "checked_at": datetime.now(timezone.utc).isoformat(), "last_batch": []} + try: + with _engine().connect() as connection: + report["schema_contract_ready"] = bool(connection.execute( + text( + "SELECT to_regclass('ai_memory_deletion_jobs') IS NOT NULL " + "AND to_regprocedure('ai_memory_purge(character varying,character varying)') IS NOT NULL" + ) + ).scalar_one()) + report["alembic_head"] = "not_readable_by_runtime_identity" + if not report["schema_contract_ready"]: + raise RuntimeError("day5_schema_contract_unavailable") + report["last_batch"] = process_outbox_batch(limit=20) + report["ok"] = True + report["error"] = "" + except Exception as exc: + report["error"] = f"{exc.__class__.__name__}:{redact_text(str(exc))[:160]}" + value["heartbeat"].write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + return report + + +def run() -> int: while True: - report = {"ok": False, "checked_at": datetime.now(timezone.utc).isoformat(), "last_batch": []} - try: - with _engine().connect() as connection: - report["schema_contract_ready"] = bool(connection.execute( - text( - "SELECT to_regclass('ai_memory_deletion_jobs') IS NOT NULL " - "AND to_regprocedure('ai_memory_purge(character varying,character varying)') IS NOT NULL" - ) - ).scalar_one()) - report["alembic_head"] = "not_readable_by_runtime_identity" - if not report["schema_contract_ready"]: - raise RuntimeError("day5_schema_contract_unavailable") - report["last_batch"] = process_outbox_batch(limit=20) - report["ok"] = True - report["error"] = "" - except Exception as exc: - report["error"] = f"{exc.__class__.__name__}:{str(exc)[:160]}" - value["heartbeat"].write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + run_once() time.sleep(2) @@ -126,12 +132,20 @@ def stop() -> dict: def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--runtime-config", action="append", type=Path, default=[]) - parser.add_argument("command", choices=("start", "status", "stop", "run")) + parser.add_argument("command", choices=("start", "status", "stop", "run", "once")) args = parser.parse_args() configure_runtime(args.runtime_config) if args.command == "run": return run() - report = start(args.runtime_config) if args.command == "start" else stop() if args.command == "stop" else status() + report = ( + start(args.runtime_config) + if args.command == "start" + else stop() + if args.command == "stop" + else run_once() + if args.command == "once" + else status() + ) print(json.dumps(report, ensure_ascii=False, indent=2, default=str)) return 0 if report["ok"] else 1 diff --git a/scripts/phase4_precheck_runtime.py b/scripts/phase4_precheck_runtime.py index a567768..0bee984 100644 --- a/scripts/phase4_precheck_runtime.py +++ b/scripts/phase4_precheck_runtime.py @@ -23,7 +23,7 @@ DEFAULT_REDIS_URL = "redis://127.0.0.1:6379/0" DEFAULT_QUEUE = "phase4_health" DEFAULT_FORECAST_QUEUE = "forecast_final_rc" -DEFAULT_KNOWLEDGE_QUEUES = "rag,embedding,report" +DEFAULT_KNOWLEDGE_QUEUES = "rag,embedding,report,data_sync,report_daily,default" WORKER_ROLES = {"health", "forecast", "knowledge"} DOCKER_TIMEOUT_SECONDS = 20 WORKER_START_TIMEOUT_SECONDS = 180 diff --git a/services/prediction_service.py b/services/prediction_service.py index 9f28f6c..b262f11 100644 --- a/services/prediction_service.py +++ b/services/prediction_service.py @@ -6,10 +6,16 @@ from typing import Any, Callable, Mapping import pandas as pd +from sqlalchemy import text from automation_common import format_duration, get_pipeline_paths, get_run_context, load_config, setup_run_logger -from database_utils import export_prediction_inputs_from_database, save_pipeline_event, sync_result_tables_to_database -from model_ops.model_registry import register_model_artifact +from database_utils import ( + export_prediction_inputs_from_database, + save_pipeline_event, + sync_result_tables_to_database, + write_excel_snapshot, +) +from model_ops.model_registry import create_model_training_engine, register_model_artifact from model_ops.prediction_tracker import build_prediction_tracking_frame, track_future_prediction_file, write_prediction_tracking from model_ops.strategy_memory import register_strategy_result from prediction_engine.fast_forecast import forecast_with_saved_model @@ -27,6 +33,95 @@ class PredictionRunResult: returncode: int +def _export_retrain_inputs_from_postgres(data_dir: Path, log: LogFunc = None) -> dict[str, Any]: + """从受限 PostgreSQL 事实表生成本次 run 独占的训练输入快照。""" + + if data_dir.exists(): + raise FileExistsError(f"显式重训输入目录已存在,拒绝覆盖:{data_dir}") + engine = create_model_training_engine() + try: + with engine.connect() as conn: + market = pd.read_sql( + text( + """ + SELECT datetime, + MAX(da_price) FILTER (WHERE da_price IS NOT NULL) AS da_price, + MAX(rt_price) FILTER (WHERE rt_price IS NOT NULL) AS rt_price + FROM raw_market + GROUP BY datetime + ORDER BY datetime + """ + ), + conn, + ) + load = pd.read_sql( + text( + """ + SELECT datetime, + MAX(actual_load) FILTER (WHERE actual_load IS NOT NULL) AS actual_load, + MAX(forecast_load) FILTER (WHERE forecast_load IS NOT NULL) AS forecast_load, + MAX(NULLIF(raw_json->>'forecast_evaluated_at', '')::timestamp) + FILTER (WHERE forecast_load IS NOT NULL) AS forecast_evaluated_at, + MAX(NULLIF(raw_json->>'forecast_evaluated_at_utc', '')::timestamptz) + FILTER (WHERE forecast_load IS NOT NULL) AS forecast_evaluated_at_utc + FROM raw_load + GROUP BY datetime + ORDER BY datetime + """ + ), + conn, + ) + weather = pd.read_sql( + text( + """ + SELECT datetime, + MAX(temperature) FILTER (WHERE temperature IS NOT NULL) AS temperature, + MAX(wind_speed) FILTER (WHERE wind_speed IS NOT NULL) AS wind_speed, + MAX(NULLIF(raw_json->>'precipitation', '')::double precision) + FILTER (WHERE raw_json->>'precipitation' IS NOT NULL) AS precipitation + FROM raw_weather + GROUP BY datetime + ORDER BY datetime + """ + ), + conn, + ) + finally: + engine.dispose() + + master = market.merge(load[["datetime", "actual_load", "forecast_load"]], on="datetime", how="inner") + master = master.merge(weather, on="datetime", how="inner") + master["price_spread_rt_minus_da"] = master["rt_price"] - master["da_price"] + required = ["da_price", "actual_load", "forecast_load", "temperature", "wind_speed", "precipitation"] + master = master.dropna(subset=required).sort_values("datetime").drop_duplicates("datetime", keep="last") + forecast = load.dropna(subset=["forecast_load"]).sort_values("datetime").drop_duplicates("datetime", keep="last") + if len(master) < 8760: + raise RuntimeError(f"PostgreSQL 训练主表有效记录不足:{len(master)} < 8760") + if master["datetime"].duplicated().any() or forecast["datetime"].duplicated().any(): + raise RuntimeError("PostgreSQL 训练输入存在重复小时") + if master[required].isna().any().any(): + raise RuntimeError("PostgreSQL 训练主表核心字段存在空值") + + data_dir.mkdir(parents=True, exist_ok=False) + write_excel_snapshot(data_dir / "master_table.xlsx", master.reset_index(drop=True)) + write_excel_snapshot(data_dir / "forecast_load_selected.xlsx", forecast.reset_index(drop=True)) + summary = { + "master_rows": int(len(master)), + "forecast_rows": int(len(forecast)), + "start": str(pd.to_datetime(master["datetime"]).min()), + "end": str(pd.to_datetime(master["datetime"]).max()), + "source": "postgresql.raw_market+raw_load+raw_weather", + "database_identity": "beta10d_forecast_login", + } + if log: + log( + "显式重训输入已从受限 PostgreSQL 导出:" + f"主表 {summary['master_rows']} 行,负荷预测 {summary['forecast_rows']} 行," + f"范围 {summary['start']} 至 {summary['end']}。" + ) + return summary + + def _is_fast_mode(env: Mapping[str, str] | None, explicit: bool) -> bool: return explicit or bool(env and str(env.get("PIPELINE_FORECAST_MODE", "")).strip().lower() == "fast") @@ -82,15 +177,30 @@ def run_prediction( config = config or load_config() paths = get_pipeline_paths(config) context = run_context or get_run_context() + strict_candidate_registration = str(context.get("run_mode") or "").strip() == "retrain_model" if log is None: log, _ = setup_run_logger(paths.log_dir, "01_run_prediction") if not paths.engine_script.exists(): raise FileNotFoundError(f"未找到预测引擎脚本:{paths.engine_script}") - log("通过 prediction_service 导出预测输入数据。") - export_prediction_inputs_from_database(paths.data_dir, config, log=log) - save_pipeline_event(config, "prediction_input_export", "completed", "已从数据库导出预测输入", context) + artifact_dir = paths.root_dir / "model_artifacts" / f"model_{context['run_id']}" + if strict_candidate_registration: + if artifact_dir.exists(): + raise FileExistsError(f"显式重训 artifact 目录已存在,拒绝覆盖:{artifact_dir}") + log("通过 prediction_service 从受限 PostgreSQL 导出本次 run 独占训练输入。") + export_summary = _export_retrain_inputs_from_postgres(paths.data_dir, log=log) + save_pipeline_event( + config, + "prediction_input_export", + "completed", + f"受限 PostgreSQL 训练输入已导出,主表 {export_summary['master_rows']} 行", + context, + ) + else: + log("通过 prediction_service 导出预测输入数据。") + export_prediction_inputs_from_database(paths.data_dir, config, log=log) + save_pipeline_event(config, "prediction_input_export", "completed", "已从数据库导出预测输入", context) if _is_fast_mode(env, fast_forecast): return _run_fast_forecast(config, context, paths, log=log) @@ -110,18 +220,47 @@ def run_prediction( sync_result_tables_to_database(paths.result_table_dir, config, run_context=context, log=log) save_pipeline_event(config, "prediction_results_sync", "completed", "预测结果数据表已同步到数据库", context) - artifact_dir = paths.root_dir / "model_artifacts" / f"model_{context['run_id']}" future_result_path = paths.result_table_dir / "18_未来24小时预测结果_正式版.xlsx" + candidate_version = "" if artifact_dir.exists(): try: - model_version = register_model_artifact(config, artifact_dir, status="candidate") - log(f"模型 artifact 已登记为 candidate:{model_version}") + candidate_version = register_model_artifact(config, artifact_dir, status="candidate") + log(f"模型 artifact 已登记为 candidate:{candidate_version}") register_strategy_result(config, artifact_dir, log=log) if future_result_path.exists(): track_future_prediction_file(config, future_result_path, artifact_dir, context["run_id"], log=log) except Exception as exc: - log(f"模型注册、策略记忆或预测追踪写入失败,不影响主流程:{exc}") + if strict_candidate_registration: + save_pipeline_event( + config, + "candidate_registration", + "failed", + "显式重训未能登记 Candidate,训练任务失败关闭", + context, + ) + raise RuntimeError("显式重训未能登记 Candidate,任务已失败关闭") from exc + log(f"模型注册、策略记忆或预测追踪写入失败,不影响普通预测主流程:{exc}") else: + if strict_candidate_registration: + save_pipeline_event( + config, + "candidate_registration", + "failed", + "显式重训未生成预期 artifact 目录", + context, + ) + raise RuntimeError(f"显式重训未生成预期 artifact 目录:{artifact_dir}") log(f"未发现本次模型 artifact 目录,跳过模型注册:{artifact_dir}") + if strict_candidate_registration: + if not candidate_version: + raise RuntimeError("显式重训没有生成可登记的 Candidate") + save_pipeline_event( + config, + "candidate_registration", + "completed", + f"Candidate 已登记:{candidate_version}", + context, + ) + return PredictionRunResult(paths.engine_script, paths.result_table_dir, elapsed, completed.returncode) diff --git a/tests/test_ai_assistant.py b/tests/test_ai_assistant.py new file mode 100644 index 0000000..256e172 --- /dev/null +++ b/tests/test_ai_assistant.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import json +import os + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text + +from backend.app.main import app +from backend.app.repositories.base import postgres_engine +from backend.app.ai_assistant.runtime_router import AssistantRoute, route_assistant_request + + +client = TestClient(app) + + +@pytest.fixture(scope="module", autouse=True) +def _controlled_report_fact_for_isolated_suite(): + if os.environ.get("BETA10D_TEST_ISOLATION_ACTIVE") != "1": + yield + return + engine = postgres_engine() + assert engine is not None + with engine.begin() as connection: + connection.execute( + text( + """ + INSERT INTO report_runs + (report_id,run_id,title,status,report_type,file_path,metadata_json, + content_json,generated_at,created_at,updated_at) + VALUES + ('report_ai_core_fixture','run_ai_core_fixture','AI 核心意图受控日报', + 'approved','daily','',CAST(:metadata AS jsonb),CAST(:content AS jsonb), + CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP) + """ + ), + { + "metadata": json.dumps({"data_origin": "controlled_test_fixture"}), + "content": json.dumps( + {"conclusion": "受控日报已生成,核心结论可供意图链路验证。"}, + ensure_ascii=False, + ), + }, + ) + yield + + +def _ask(question: str) -> dict: + response = client.post("/api/ai/chat", json={"question": question, "debug": True}) + assert response.status_code == 200 + payload = response.json() + assert payload["answer"] + assert "必须买入" not in payload["answer"] + assert "保证收益" not in payload["answer"] + return payload + + +def test_ai_assistant_hides_debug_fields_by_default(): + response = client.post("/api/ai/chat", json={"question": "今天星期几?"}) + assert response.status_code == 200 + payload = response.json() + assert payload["answer"] + for field in ["intent", "tools", "tool_calls", "trace", "agent_trace", "workflow"]: + assert field not in payload + assert "AI意图" not in payload["answer"] + assert "Trace" not in payload["answer"] + + +def test_ai_assistant_forecast_extreme_is_question_specific(): + payload = _ask("\u660e\u5929\u4ec0\u4e48\u65f6\u5019\u7535\u4ef7\u6700\u9ad8\uff1f") + assert payload["intent"] == "forecast_max_price" + assert payload["evidence"] + assert payload["tool_calls"][0]["tool_name"] == "get_forecast_metrics" + assert "\u6700\u9ad8\u7535\u4ef7" in payload["answer"] or "\u6700\u9ad8\u4ef7" in payload["answer"] + + +def test_ai_assistant_hour_explain_uses_hour_tool(): + payload = _ask("\u4e3a\u4ec0\u4e4818\u70b9\u98ce\u9669\u9ad8\uff1f") + assert payload["intent"] == "risk_reason" + assert [call["tool_name"] for call in payload["tool_calls"]] == ["explain_high_price_hour"] + assert "18:00" in payload["answer"] + + +def test_ai_assistant_core_intents(): + cases = { + "\u54ea\u4e9b\u5c0f\u65f6\u98ce\u9669\u6700\u9ad8\uff1f": "forecast_risk_hours", + "\u4eca\u5929\u4ea4\u6613\u7b56\u7565\u600e\u4e48\u505a\uff1f": "trading_risk_summary", + "\u54ea\u4e9b\u5c0f\u65f6\u9002\u5408\u50a8\u80fd\u653e\u7535\uff1f": "storage_discharge_advice", + "\u6a21\u578b\u6700\u8fd1\u8bef\u5dee\u53d8\u5927\u4e86\u5417\uff1f": "model_error_status", + "\u4eca\u5929\u65e5\u62a5\u6838\u5fc3\u7ed3\u8bba\u662f\u4ec0\u4e48\uff1f": "report_summary", + } + for question, intent in cases.items(): + payload = _ask(question) + assert payload["intent"] == intent + assert payload["evidence"], question + assert payload["tool_calls"] + assert route_assistant_request("今天数据更新了吗?") == AssistantRoute.CHATBI + + +def test_stage1_acceptance_questions_are_answerable(): + cases = { + "\u660e\u5929\u5e7f\u4e1c\u7535\u4ef7\u600e\u4e48\u770b\uff1f": "trading_risk_summary", + "\u4e3a\u4ec0\u4e48\u660e\u5929\u7535\u4ef7\u53ef\u80fd\u4e0a\u6da8\uff1f": "trading_risk_summary", + "\u665a\u9ad8\u5cf0\u6709\u6ca1\u6709\u9ad8\u4ef7\u98ce\u9669\uff1f": "forecast_risk_hours", + "\u5f53\u524d\u9884\u6d4b\u7ed3\u679c\u4e3b\u8981\u53d7\u54ea\u4e9b\u56e0\u7d20\u5f71\u54cd\uff1f": "trading_risk_summary", + "\u8d1f\u8377\u504f\u9ad8\u4e3a\u4ec0\u4e48\u4f1a\u63a8\u9ad8\u7535\u4ef7\uff1f": "load_price_explanation", + "\u65b0\u80fd\u6e90\u51fa\u529b\u4e0b\u964d\u4f1a\u9020\u6210\u4ec0\u4e48\u5f71\u54cd\uff1f": "trading_risk_summary", + "\u8fd9\u4e2a\u9884\u6d4b\u7ed3\u679c\u53ef\u9760\u5417\uff1f": "model_error_status", + "\u5e2e\u6211\u751f\u6210\u4e00\u6bb5\u4eca\u5929\u7684\u7535\u4ef7\u5206\u6790\u65e5\u62a5\u3002": "report_summary", + } + for question, intent in cases.items(): + response = client.post("/api/ai/chat", json={"question": question, "market": "\u5e7f\u4e1c", "debug": True}) + assert response.status_code == 200 + payload = response.json() + assert payload["intent"] == intent + assert payload["answer"] + assert payload["trace_id"].startswith("trace_") + assert isinstance(payload["data_used"], dict) + assert isinstance(payload["suggestions"], list) + assert "必须买入" not in payload["answer"] + assert "保证收益" not in payload["answer"] + + +def test_weather_question_answers_weather_not_power_price(): + payload = _ask("\u660e\u5929\u5929\u6c14\u600e\u4e48\u6837\uff1f") + assert payload["intent"] == "weather_summary" + assert payload["data_used"]["weather"] is True + assert "\u5929\u6c14" in payload["answer"] or "\u6c14\u6e29" in payload["answer"] + assert "\u6700\u9ad8\u7535\u4ef7" not in payload["answer"] + + +def test_stage2_agent_analyze_runs_multi_agent_workflow(): + response = client.post( + "/api/ai/agent/analyze", + json={ + "question": "\u5e2e\u6211\u5206\u6790\u660e\u5929\u5e7f\u4e1c\u7535\u4ef7\u4e0a\u6da8\u539f\u56e0\uff0c\u5e76\u7ed9\u51fa\u4ea4\u6613\u98ce\u9669\u63d0\u793a\u3002", + "market": "\u5e7f\u4e1c", + "debug": True, + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["intent"] == "trading_risk_summary" + assert payload["complexity"] == "complex" + assert payload["answer"] + for node in ["input_normalizer", "context_resolver", "intent_router", "tool_executor", "answer_guard"]: + assert node in payload["workflow"] + assert len(payload["agent_trace"]) >= 5 + used = payload["data_used"] + assert used["prediction"] is True + assert used["weather"] is True + assert used["model"] is True + assert "交易" in payload["answer"] or "风险" in payload["answer"] + + +def test_chat_uses_stage2_agent_for_complex_business_question(): + payload = _ask("\u660e\u5929\u5e7f\u4e1c\u7535\u4ef7\u4e0a\u6da8\u539f\u56e0\u548c\u4ea4\u6613\u98ce\u9669\u662f\u4ec0\u4e48\uff1f") + assert payload["intent"] == "trading_risk_summary" + assert "workflow" in payload + assert "tool_executor" in payload["workflow"] + assert payload["data_used"]["prediction"] is True + assert payload["data_used"]["weather"] is True + assert "\u5929\u6c14\u600e\u4e48\u6837" not in payload["answer"] diff --git a/tests/test_ai_assistant_accuracy.py b/tests/test_ai_assistant_accuracy.py new file mode 100644 index 0000000..811e3ae --- /dev/null +++ b/tests/test_ai_assistant_accuracy.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from backend.app.ai_assistant.runtime_router import AssistantRoute, route_assistant_request +from backend.app.ai_assistant.service import answer_chat_accurate +from backend.app.main import app + + +client = TestClient(app) + + +def _chat(question: str, session_id: str | None = None) -> dict: + payload = {"question": question, "debug": True} + if session_id: + payload["session_id"] = session_id + response = client.post("/api/ai/chat", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["answer"] + assert data["trace"]["guard_result"] in {"passed", "rewritten"} + return data + + +def test_weather_data_latest_time_answer(): + assert route_assistant_request("天气数据截止的日期") == AssistantRoute.CHATBI + data = answer_chat_accurate("天气数据截止的日期", debug=True, persist=False) + assert data["intent"] == "weather_data_latest_time" + assert data["tools"][0]["name"] == "get_data_freshness" + assert "最新时间" in data["answer"] + assert "数据范围" in data["answer"] + assert "weather_observations" in data["answer"] + assert "raw_weather" not in data["answer"] + + +def test_current_date_query_answer(): + data = _chat("今天是几号") + assert data["intent"] == "current_date_query" + assert data["tools"][0]["name"] == "get_current_date_context" + assert "当前日期" in data["answer"] + assert "不需要调用电力预测数据" not in data["answer"] + + +def test_storage_discharge_has_multiple_windows(): + data = _chat("哪些时段适合储能放电?") + assert data["intent"] == "storage_discharge_advice" + assert data["tools"][0]["name"] == "get_storage_discharge_windows" + output = data["tool_calls"][0]["output"] + assert len(output["recommended_discharge_hours"]) >= 3 + assert "低价充电参考" in data["answer"] + assert "峰谷价差" in data["answer"] + + +def test_followup_reason_uses_previous_low_price_context(): + first = _chat("明天最低电价是什么时候?", session_id="test_followup_low") + assert first["intent"] == "forecast_min_price" + second = _chat("为什么", session_id=first["session_id"]) + assert second["intent"] == "low_price_reason" + assert "低价窗口" in second["answer"] + assert "原因" in second["answer"] or "依据" in second["answer"] + + +def test_user_provided_report_text_is_explained(): + question = ( + "本次运行专属电价智能分析报告基于运行ID 20260528_163940生成," + "数据窗口为2024-05-28 00:00:00至2026-05-25 23:00:00," + "预测窗口为2026-05-26 00:00:00至2026-05-26 23:00:00。" + "未来24小时均价为61.9582 USD/MWh,最高价194.0627 USD/MWh出现在17:00," + "最低价-26.9293 USD/MWh出现在06:00,峰谷价差达220.9920 USD/MWh。" + "模型采用高峰增强融合模型,测试集RMSE为13.6538,MAE为7.4830,R2为0.8835。帮我解答" + ) + data = _chat(question) + assert data["intent"] == "user_provided_text_explain" + assert data["tools"][0]["name"] == "explain_user_provided_text" + for keyword in ["预测窗口", "最高价", "最低价", "峰谷价差"]: + assert keyword in data["answer"] diff --git a/tests/test_ai_assistant_runtime_hotfix.py b/tests/test_ai_assistant_runtime_hotfix.py new file mode 100644 index 0000000..5191336 --- /dev/null +++ b/tests/test_ai_assistant_runtime_hotfix.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from backend.app.ai_assistant.runtime_router import AssistantRoute, route_assistant_request, route_requires_rag +from backend.app.ai_assistant.core.intent_router import route_intent +from backend.app.ai_assistant.schemas import IntentDecision, ToolResult +from backend.app.ai_assistant.service import _build_answer, _evidence, _should_use_rag +from backend.app.ai_assistant.core.tool_router import tools_for_intent +from backend.app.ai_assistant.templates.fallback_answers import answer_high_price_reason +from backend.app.chatbi.planner import generate_analysis_plan, parse_analysis_plan, repair_analysis_plan +from backend.app.services.rag_qdrant_transport import _preproduction_candidate_is_accepted +from backend.app.services import rag_qdrant_transport + + +def test_required_questions_are_routed_before_enterprise_runtime() -> None: + assert route_assistant_request("今天几号?") == AssistantRoute.GENERAL_CHAT + assert route_assistant_request("数据库最新的天气数据是哪天?") == AssistantRoute.CHATBI + assert route_assistant_request("对比历史同期情况") == AssistantRoute.RAG_QA + assert route_assistant_request("解释高价风险原因") == AssistantRoute.BUSINESS_ANALYSIS + + +def test_grouped_metric_query_is_stably_routed_to_chatbi() -> None: + question = "查询 2020-01-01 全天日前电价平均值,按市场分组并用表格展示。" + + assert route_intent(question).intent == "data_sql_query" + assert route_assistant_request(question) == AssistantRoute.CHATBI + + +def test_business_tool_routes_do_not_open_rag_as_a_hard_prerequisite() -> None: + assert route_requires_rag(AssistantRoute.BUSINESS_ANALYSIS) is False + assert route_requires_rag(AssistantRoute.BUSINESS_ADVICE) is False + assert route_requires_rag(AssistantRoute.REPORT_GENERATION) is False + assert route_requires_rag(AssistantRoute.RAG_QA) is True + assert _should_use_rag("high_price_reason", "complex_analysis", "解释高价风险原因") is False + assert _should_use_rag("report_summary", "complex_analysis", "生成完整分析报告") is False + assert _should_use_rag("knowledge_search", "complex_analysis", "对比历史同期情况") is True + + +def test_purchase_advice_and_policy_questions_use_business_routes() -> None: + assert route_intent("建议购电策略").intent == "trading_risk_summary" + assert route_assistant_request( + "建议购电策略", answer_style="business_advice" + ) == AssistantRoute.BUSINESS_ADVICE + assert route_intent("政策解读与影响").intent == "tariff_policy_search" + assert route_assistant_request("政策解读与影响") == AssistantRoute.RAG_QA + + +def test_full_matrix_prediction_questions_use_specific_business_tools() -> None: + assert route_intent("查看明日分时电价预测").intent == "forecast_overview" + assert route_intent("新能源出力预测").intent == "renewable_forecast_analysis" + assert route_intent("高峰时段负荷预测").intent == "load_forecast_analysis" + for question in ("查看明日分时电价预测", "新能源出力预测", "高峰时段负荷预测"): + assert route_assistant_request(question) == AssistantRoute.BUSINESS_ANALYSIS + assert tools_for_intent("forecast_overview") == ["get_forecast_metrics"] + assert tools_for_intent("load_forecast_analysis") == ["get_load_forecast"] + assert tools_for_intent("renewable_forecast_analysis") == ["get_renewable_forecast"] + + +def test_prediction_tool_answers_keep_unavailable_boundaries_and_evidence() -> None: + renewable = ToolResult( + "get_renewable_forecast", + {}, + {"available": False, "availability": "unavailable", "message": "当前系统未接入新能源出力预测数据。"}, + ) + answer = _build_answer( + IntentDecision("renewable_forecast_analysis", 0.96, {}, "新能源出力预测"), + [renewable], + ) + assert "未查询到可用新能源出力预测数据" in answer + assert "不能编造" in answer + assert _evidence([renewable])[0]["availability"] == "unavailable" + + +def test_plan_parser_normalizes_provider_wrappers_and_camel_case() -> None: + plan = parse_analysis_plan({ + "message": { + "reasoning_content": "internal reasoning is ignored", + "content": "```json\n" + json.dumps({ + "analysisPlan": { + "dataSets": ["weather_history"], + "metrics": ["avg_temperature"], + "timeRange": { + "start": "2026-08-01T00:00:00", + "end": "2026-08-12T23:59:59", + }, + "chartIntent": "line", + } + }) + "\n```", + } + }) + assert plan.datasets == ["weather_history"] + assert plan.metrics == ["avg_temperature"] + assert plan.chart_intent == "line" + + +class _RepairingPlanner: + def __init__(self) -> None: + self.calls = 0 + + def generate_answer(self, messages, **_kwargs): + self.calls += 1 + if self.calls == 1: + return "not-json", {"provider": "deepseek", "model": "test"} + assert '"repair":true' in messages[-1]["content"] + return json.dumps({"metrics": ["avg_temperature"]}), { + "provider": "deepseek", "model": "test", "finish_reason": "stop" + } + + +def test_planner_performs_one_targeted_repair() -> None: + router = _RepairingPlanner() + plan, metadata = generate_analysis_plan("数据库最新天气是哪天?", None, router=router) + assert router.calls == 2 + assert plan.metrics == ["avg_temperature"] + assert metadata["repair_attempted"] is True + assert metadata["finish_reason"] == "stop" + + +def test_planner_reserves_reasoning_budget_for_structured_output() -> None: + class BudgetRouter: + def __init__(self) -> None: + self.max_tokens: list[int] = [] + + def generate_answer(self, messages, **kwargs): + self.max_tokens.append(kwargs["max_tokens"]) + return '{"datasets":[],"metrics":[],"dimensions":[],"filters":[],"group_by":[],"order_by":[],"limit":100,"joins":[],"chart_intent":"table","analysis_mode":"aggregate","clarification_required":true,"clarification_question":"请补充指标"}', {"provider": "deepseek", "model": "deepseek-v4-flash"} + + router = BudgetRouter() + plan, _ = generate_analysis_plan("分析数据", {}, requested_provider="deepseek", router=router) + assert plan.clarification_required is True + assert router.max_tokens == [6000] + + +def test_complete_grouped_market_query_recovers_from_bad_llm_clarification() -> None: + class BadClarificationRouter: + def generate_answer(self, messages, **kwargs): + return json.dumps({ + "clarification_required": True, + "clarification_question": "请补充指标、时间范围和分组维度。", + }, ensure_ascii=False), { + "provider": "deepseek", + "model": "deepseek-v4-flash", + "finish_reason": "stop", + } + + question = "查询 2020-01-01 全天日前电价平均值,按市场分组并用表格展示。" + plan, metadata = generate_analysis_plan( + question, None, router=BadClarificationRouter() + ) + + assert plan.datasets == ["market_price_history"] + assert plan.metrics == ["avg_day_ahead_price"] + assert plan.dimensions == ["market_observed_at", "market_code"] + assert plan.group_by == ["market_code"] + assert plan.time_range.start == "2020-01-01T00:00:00" + assert plan.time_range.end == "2020-01-01T23:59:59" + assert plan.chart_intent == "table" + assert plan.clarification_required is False + assert metadata["complete_market_plan_normalized"] is True + + +def test_deterministic_validation_repair_is_single_call() -> None: + class RepairRouter: + def __init__(self) -> None: + self.calls = 0 + + def generate_answer(self, messages, **kwargs): + self.calls += 1 + assert '"metric_required"' in messages[-1]["content"] + return json.dumps({ + "datasets": ["weather_observations"], + "metrics": ["avg_temperature"], + "dimensions": ["weather_observed_at"], + "order_by": [{"field": "weather_observed_at", "direction": "desc"}], + "limit": 1, + "chart_intent": "table", + "analysis_mode": "aggregate", + }), {"provider": "deepseek", "model": "deepseek-v4-flash", "finish_reason": "stop"} + + invalid = parse_analysis_plan(json.dumps({ + "datasets": ["weather_observations"], + "metrics": [], + "dimensions": ["weather_observed_at"], + })) + router = RepairRouter() + plan, metadata = repair_analysis_plan( + "数据库最新的天气数据是哪天?", + {}, + invalid, + [{"code": "metric_required", "field": "metrics", "message": "指标必填"}], + requested_provider="deepseek", + router=router, + ) + assert router.calls == 1 + assert plan.metrics == ["avg_temperature"] + assert metadata["repair_attempted"] is True + + +def test_latest_weather_plan_is_normalized_to_registered_contract() -> None: + class LatestWeatherRouter: + def generate_answer(self, messages, **kwargs): + return json.dumps({ + "datasets": ["weather_observations"], + "metrics": [], + "dimensions": ["weather_observed_at"], + "order_by": [{"field": "weather_observed_at", "direction": "desc"}], + "limit": 1, + "chart_intent": "table", + "analysis_mode": "ranking", + }), {"provider": "deepseek", "model": "deepseek-v4-flash", "finish_reason": "stop"} + + plan, metadata = generate_analysis_plan( + "数据库最新的天气数据是哪天?", {}, requested_provider="deepseek", router=LatestWeatherRouter() + ) + assert plan.metrics == ["avg_temperature"] + assert plan.group_by == ["weather_observed_at"] + assert plan.analysis_mode == "aggregate" + assert metadata["latest_fact_normalized"] is True + + +def test_high_price_empty_fact_is_explicit_and_contains_no_none() -> None: + answer = answer_high_price_reason({}) + assert "暂不能判断" in answer + assert "None" not in answer + + +def test_frozen_rag_r1_candidate_evidence_is_accepted_without_alias_switch( + monkeypatch, tmp_path: Path +) -> None: + payloads = { + "candidate_build_report.json": {"status": "PASS", "release_id": "RAG-R1"}, + "candidate_collection_report.json": { + "status": "PASS", + "release_id": "RAG-R1", + "collection": "rag_chunks_RAG-R1", + "point_count": 8339, + "alias_before": None, + "alias_after": None, + }, + "embedding_manifest.json": { + "release_id": "RAG-R1", + "chunk_count": 8339, + "dimension": 1024, + }, + "candidate_release_envelope.json": { + "candidate_release_id": "RAG-R1", + "release_status": "candidate", + }, + } + for name, payload in payloads.items(): + (tmp_path / name).write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setattr( + rag_qdrant_transport, "EXPECTED_RELEASE_ROOT", tmp_path.resolve() + ) + assert _preproduction_candidate_is_accepted("RAG-R1", "rag_chunks_RAG-R1") diff --git a/tests/test_ai_chat_endpoint_contract.py b/tests/test_ai_chat_endpoint_contract.py index 543a355..383142a 100644 --- a/tests/test_ai_chat_endpoint_contract.py +++ b/tests/test_ai_chat_endpoint_contract.py @@ -1,5 +1,6 @@ from __future__ import annotations +import fitz from fastapi.testclient import TestClient from backend.app.api.v1.endpoints import assistant as assistant_endpoint @@ -136,6 +137,72 @@ def fake_answer_chat(question, **kwargs): assert captured["question"] == "测试流式问答" +def _chatbi_result(answer: str = "查询返回 1 组可复算结果。平均日前电价最高值为 103 元/MWh。") -> dict: + return { + "available": True, + "state": "success", + "answer": answer, + "answer_source": "grounded_narrative", + "result_dataset": {"row_count": 1, "result_hash": "r" * 64}, + "narrative": {"text": answer, "citations": []}, + "planner": {"provider": "deepseek", "model": "deepseek-chat", "fallback": False}, + "lineage": {"analysis_plan_id": "plan_test", "result_hash": "r" * 64}, + } + + +def test_ai_chat_auto_route_uses_governed_chatbi_final_answer(monkeypatch): + captured: dict[str, object] = {} + + def fake_chatbi_turn(**kwargs): + captured.update(kwargs) + return _chatbi_result() + + monkeypatch.setattr(assistant_endpoint, "execute_chatbi_turn", fake_chatbi_turn) + + response = client.post("/api/ai/chat", json={"question": "数据库最新的天气数据是哪天?"}) + + assert response.status_code == 200 + payload = response.json() + assert payload["assistant_route"] == "CHATBI" + assert payload["answer"] == payload["narrative"]["text"] + assert payload["answer"] != payload["state"] + assert payload["answer_source"] == "grounded_narrative" + assert captured["requested_provider"] == "deepseek" + + +def test_ai_chat_explicit_chatbi_rejects_status_word_as_final_answer(monkeypatch): + monkeypatch.setattr( + assistant_endpoint, + "execute_chatbi_turn", + lambda **_kwargs: _chatbi_result(answer="success"), + ) + + response = client.post( + "/api/ai/chat", + json={"question": "按市场分析平均日前电价", "mode": "chatbi"}, + ) + + assert response.status_code == 503 + assert response.json()["detail"]["code"] == "CHATBI_ANSWER_CONTRACT_INVALID" + + +def test_ai_chat_stream_emits_chatbi_narrative_not_state_word(monkeypatch): + monkeypatch.setattr( + assistant_endpoint, + "execute_chatbi_turn", + lambda **_kwargs: _chatbi_result(), + ) + + response = client.post( + "/api/ai/chat/stream", + json={"question": "按市场分析平均日前电价", "mode": "chatbi"}, + ) + + assert response.status_code == 200 + assert "平均日前电价最高值" in response.text + assert '"text": "success"' not in response.text + + def test_ai_export_conversation_docx(): response = client.post( "/api/ai/chat/sessions/export?format=docx", @@ -155,14 +222,65 @@ def test_ai_export_conversation_docx(): assert response.content.startswith(b"PK") -def test_ai_export_pdf_reports_unavailable(): +def test_ai_export_conversation_pdf_is_searchable_and_redacted(): + response = client.post( + "/api/ai/chat/sessions/export?format=pdf", + json={ + "session_id": "export_session", + "messages": [ + { + "role": "user", + "content": "请分析明日电价,password=must-not-leak", + "created_at": "10:00", + "trace": {"hidden": True}, + }, + {"role": "assistant", "content": "结论:已生成分析。", "created_at": "10:01"}, + {"role": "system", "content": "hidden system prompt"}, + ], + }, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/pdf") + assert response.content.startswith(b"%PDF") + document = fitz.open(stream=response.content, filetype="pdf") + exported_text = "".join(page.get_text() for page in document).replace("\xa0", " ") + assert "AI 助手会话导出" in exported_text + assert "请分析明日电价" in exported_text + assert "must-not-leak" not in exported_text + assert "[REDACTED]" in exported_text + assert "hidden system prompt" not in exported_text + assert "hidden" not in exported_text + + +def test_ai_export_conversation_rejects_oversized_message_count(): response = client.post( "/api/ai/chat/sessions/export?format=pdf", - json={"session_id": "export_session", "messages": []}, + json={ + "session_id": "export_session", + "messages": [{"role": "user", "content": "x"}] * (assistant_endpoint.MAX_EXPORT_MESSAGES + 1), + }, ) + assert response.status_code == 422 + - assert response.status_code == 501 - assert "PDF" in response.json()["detail"] +def test_ai_export_conversation_pdf_paginates_long_chinese_content(): + response = client.post( + "/api/ai/chat/sessions/export?format=pdf", + json={ + "session_id": "long_export_session", + "messages": [ + {"role": "user", "content": f"第 {index} 条:" + "新能源经营分析依据。" * 30} + for index in range(24) + ], + }, + ) + assert response.status_code == 200 + document = fitz.open(stream=response.content, filetype="pdf") + assert document.page_count >= 3 + text = "".join(page.get_text() for page in document).replace("\xa0", " ") + assert "第 0 条" in text and "第 23 条" in text + assert f"第 {document.page_count} / {document.page_count} 页" in text def test_ai_upload_attachment_saves_metadata(monkeypatch, tmp_path): @@ -185,8 +303,8 @@ def test_ai_upload_attachment_saves_metadata(monkeypatch, tmp_path): ) assert detail.status_code == 200 assert "chunks" not in detail.json() and "properties" not in detail.json() - deleted = client.delete(f"/api/ai/attachments/{payload['attachment_id']}") - deleted_again = client.delete(f"/api/ai/attachments/{payload['attachment_id']}") + deleted = client.delete(f"/api/ai/attachments/{payload['attachment_id']}?session_id=sess_upload") + deleted_again = client.delete(f"/api/ai/attachments/{payload['attachment_id']}?session_id=sess_upload") assert deleted.status_code == 200 and deleted.json()["status"] == "deleted" assert deleted_again.status_code == 200 and deleted_again.json()["status"] == "deleted" assert [item["action"] for item in audits] == [ @@ -223,16 +341,22 @@ def jwt_user(user_id: str, tenant_id: str) -> CurrentUser: attachment_id = uploaded.json()["attachment_id"] app.dependency_overrides[get_current_user] = lambda: jwt_user("user-b", "tenant-a") - assert client.get(f"/api/ai/attachments/{attachment_id}").status_code == 404 - assert client.delete(f"/api/ai/attachments/{attachment_id}").status_code == 404 + assert client.get(f"/api/ai/attachments/{attachment_id}?session_id=sess-owned").status_code == 404 + assert client.delete(f"/api/ai/attachments/{attachment_id}?session_id=sess-owned").status_code == 404 app.dependency_overrides[get_current_user] = lambda: jwt_user("user-a", "tenant-b") - assert client.get(f"/api/ai/attachments/{attachment_id}").status_code == 404 + assert client.get(f"/api/ai/attachments/{attachment_id}?session_id=sess-owned").status_code == 404 app.dependency_overrides[get_current_user] = lambda: jwt_user("user-a", "tenant-a") + assert client.get(f"/api/ai/attachments/{attachment_id}").status_code == 422 + assert client.delete(f"/api/ai/attachments/{attachment_id}").status_code == 422 wrong_session = client.get(f"/api/ai/attachments/{attachment_id}?session_id=sess-other") assert wrong_session.status_code == 403 - assert client.get("/api/ai/attachments/att_00000000000000000000000000000000").status_code == 404 + wrong_delete = client.delete(f"/api/ai/attachments/{attachment_id}?session_id=sess-other") + assert wrong_delete.status_code == 403 + assert client.get( + "/api/ai/attachments/att_00000000000000000000000000000000?session_id=sess-owned" + ).status_code == 404 finally: if original is None: app.dependency_overrides.pop(get_current_user, None) @@ -296,7 +420,7 @@ def grounded_file_answer(question, **kwargs): assert captured["attachment_ids"] == [attachment_id] assert captured["knowledge_scope"] == "attachments" - assert client.delete(f"/api/ai/attachments/{attachment_id}").status_code == 200 + assert client.delete(f"/api/ai/attachments/{attachment_id}?session_id=sess_file").status_code == 200 reused = client.post("/api/ai/chat", json={ "question": "再次使用附件。", "session_id": "sess_file", diff --git a/tests/test_ai_runtime_release_contract.py b/tests/test_ai_runtime_release_contract.py index 1fbe953..831e494 100644 --- a/tests/test_ai_runtime_release_contract.py +++ b/tests/test_ai_runtime_release_contract.py @@ -28,8 +28,10 @@ from backend.app.ai_assistant.runtime_router import AssistantRoute, answer_strategy, route_assistant_request -def identity(tenant: str = "tenant-a", user: str = "user-a", session: str = "sess-a") -> IdentityContext: - return IdentityContext(tenant, "workspace", user, ("analyst",), session_id=session) +def identity( + tenant: str = "tenant-a", user: str = "user-a", session: str = "sess-a", workspace: str = "workspace" +) -> IdentityContext: + return IdentityContext(tenant, workspace, user, ("analyst",), session_id=session) def _image(fmt: str) -> bytes: @@ -105,11 +107,14 @@ def test_attachment_scope_prompt_injection_citation_and_formula_safety(monkeypat assert "[formula-text]" in context["untrusted_text"] assert " None: + migration = importlib.import_module( + "migrations.versions.0024_runtime_role_permissions_v2" + ) + + assert set(migration.RUNTIME_DEFAULTS) == { + "analyst", + "developer", + "viewer", + "reviewer", + "operator", + } + for role_id, permissions in migration.RUNTIME_DEFAULTS.items(): + assert set(permissions) == ROLE_PERMISSIONS[role_id] + assert migration.INTERMEDIATE_DEFAULTS == { + "reviewer": [ + "report:download", + "report:read", + "report:review", + "strategy:read", + "strategy:review", + ] + } + assert set(migration.INTERMEDIATE_DEFAULTS["reviewer"]) < set( + migration.RUNTIME_DEFAULTS["reviewer"] + ) diff --git a/tests/test_chatbi_package3.py b/tests/test_chatbi_package3.py index 49f0278..b42395c 100644 --- a/tests/test_chatbi_package3.py +++ b/tests/test_chatbi_package3.py @@ -2,6 +2,7 @@ import json import os +from contextlib import contextmanager import pytest from fastapi.testclient import TestClient @@ -86,6 +87,46 @@ def test_result_schema_serializes_under_required_schema_key() -> None: assert payload["schema"][1]["unit"] == "元/MWh" +def test_service_exposes_display_answer_for_result_and_clarification(monkeypatch) -> None: + class FakeEngine: + @contextmanager + def begin(self): + yield object() + + service_path = "backend.app.chatbi.service" + monkeypatch.setattr(f"{service_path}._insert_audit", lambda *_args, **_kwargs: None) + monkeypatch.setattr(f"{service_path}._finish_audit", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + f"{service_path}.execute_result_dataset", + lambda server_plan, *_args, **_kwargs: result().model_copy( + update={"analysis_plan_id": server_plan.analysis_plan_id} + ), + ) + scoped_identity = IdentityContext( + tenant_id="tenant_a", workspace_id="workspace_a", user_id="user_a", + role_ids=("analyst",), agent_id="chatbi", session_id="session_a", run_id="run_a", + ) + permissions = ("assistant:use", "data:read", "model:read") + + answered = execute_chatbi_analysis( + question="按市场分析平均日前电价", plan=plan(), identity=scoped_identity, + permissions=permissions, engine=FakeEngine(), + ) + assert answered["answer"] == answered["narrative"]["text"] + assert answered["answer"] != answered["state"] + assert answered["answer_source"] == "grounded_narrative" + + clarification = execute_chatbi_analysis( + question="分析一下", plan=AnalysisPlan.model_validate({ + "clarification_required": True, + "clarification_question": "请说明要分析的指标和时间范围。", + }), identity=scoped_identity.with_session("session_b", run_id="run_b"), + permissions=permissions, engine=FakeEngine(), + ) + assert clarification["answer"] == clarification["clarification"]["question"] + assert clarification["answer_source"] == "clarification" + + @pytest.mark.skipif( os.environ.get("BETA10D_TEST_DATABASE_MODE") != "isolated-schema", reason="isolated PostgreSQL gate only", diff --git a/tests/test_data_trust_service.py b/tests/test_data_trust_service.py new file mode 100644 index 0000000..9ea3113 --- /dev/null +++ b/tests/test_data_trust_service.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from backend.app.ai_assistant.runtime_router import AssistantRoute, route_assistant_request +from backend.app.ai_assistant.templates.deterministic_answers import answer_data_sql_query +from backend.app.main import app +from backend.app.services import data_trust_service +from backend.app.services.controlled_business_query_service import query_business_data +from backend.app.services.dataset_query_service import dataset_fields, list_registered_datasets + + +client = TestClient(app) +ANALYST = {"X-User": "day4_analyst", "X-Role": "analyst"} + + +def test_dataset_catalog_and_field_mapping_hide_physical_objects(): + catalog = list_registered_datasets(search="天气") + assert catalog["available"] is True + item = next(row for row in catalog["datasets"] if row["dataset_id"] == "weather_observations") + assert "object_name" not in item + assert "table_name" not in item + fields = dataset_fields("weather_observations") + assert any(field["field_id"] == "temperature_c" for field in fields["fields"]) + assert all("column_name" not in field for field in fields["fields"]) + + +def test_legacy_free_sql_function_is_permanently_disabled(monkeypatch): + monkeypatch.setattr(data_trust_service, "database_engine", lambda: (_ for _ in ()).throw(AssertionError("database reached"))) + for statement in ( + "SELECT 1", + "SELECT * FROM users", + "SELECT pg_read_file('postgresql.conf')", + "DELETE FROM raw_weather", + ): + result = data_trust_service.execute_read_only_sql(statement) + assert result["available"] is False + assert result["safe"] is False + assert "永久禁用" in result["not_found_reason"] + assert "sql" not in result + + +def test_controlled_ai_business_query_uses_registered_dataset_and_public_fields(): + result = query_business_data("请查一下天气观测最新 2 条温度数据", dataset_ids=["weather_observations"], limit=2) + assert result["safe"] is True + assert result["dataset_id"] == "weather_observations" + assert "temperature_c" in result["fields"] + assert result["query_summary"] + assert len(result["records"]) <= 2 + assert all("raw_json" not in row and "source_file" not in row for row in result["records"]) + answer = answer_data_sql_query(result) + assert "数据集:weather_observations" in answer + assert "受控数据集查询" in answer + + +def test_ai_query_blocks_sensitive_and_unregistered_objects(): + for target in ("users", "audit_logs", "pg_authid", "information_schema"): + result = query_business_data(f"查询 {target}", dataset_ids=[target]) + assert result["available"] is False + assert result["safe"] is False + assert result["records"] == [] + + +def test_ai_catalog_empty_dataset_and_readiness_use_dataset_contract(): + catalog = query_business_data("当前数据库有哪些核心业务表?") + assert catalog["query_type"] == "data_catalog_list" + assert "dataset_id" in catalog["fields"] + assert all("table_name" not in row for row in catalog["records"]) + + empty = query_business_data("哪些数据集当前为空?") + assert empty["query_type"] == "empty_dataset_scan" + assert "查询摘要" in answer_data_sql_query(empty) + + readiness = query_business_data("当前数据是否足够支撑预测?") + assert readiness["query_type"] == "prediction_readiness" + assert readiness["dataset_id"] == "prediction_readiness" + + +def test_data_endpoints_expose_only_registered_contract_and_sql_returns_410(): + catalog = client.get("/api/data/catalog", headers=ANALYST) + fields = client.get("/api/data/fields?dataset_id=weather_observations", headers=ANALYST) + blocked = client.post("/api/data/sql/query", headers=ANALYST, json={"sql": "SELECT * FROM users"}) + assert catalog.status_code == 200 and catalog.json()["datasets"] + assert fields.status_code == 200 and fields.json()["fields"] + assert blocked.status_code == 410 + assert blocked.json()["detail"] == { + "code": "arbitrary_sql_disabled", + "message": "任意 SQL 查询接口已永久禁用,请使用已注册数据集接口。", + } + + +def test_ai_chat_business_data_query_routes_to_chatbi_and_keeps_controlled_tool(): + question = "请查询 raw_weather 最新 2 条记录" + assert route_assistant_request(question) == AssistantRoute.CHATBI + result = query_business_data(question, dataset_ids=["weather_observations"], limit=2) + assert result["safe"] is True + assert result["dataset_id"] == "weather_observations" + assert len(result["records"]) <= 2 diff --git a/tests/test_day3_api_security.py b/tests/test_day3_api_security.py new file mode 100644 index 0000000..08cbbb9 --- /dev/null +++ b/tests/test_day3_api_security.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import csv +import time +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from backend.app.auth.jwt import create_access_token +from backend.app.core import security +from backend.app.core.api_security import ( + DEVELOPMENT_DOC_ROUTE_KEYS, + MATRIX_PATH, + PUBLIC_ROUTE_KEYS, + ApiSecurityMiddleware, + app_route_keys, + load_route_policies, + validate_app_route_coverage, +) +from backend.app.core.config import reset_settings_cache +from backend.app.core.security import CurrentUser, ROLE_PERMISSIONS, get_current_user +from backend.app.main import app, create_app +from scripts.day3_generate_permission_matrix import build_rows + + +pytestmark = pytest.mark.no_legacy_auth +STRONG_SECRET = "day3-unit-test-secret-0123456789abcdef" +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture(autouse=True) +def _security_env(monkeypatch): + monkeypatch.setenv("APP_ENV", "test") + monkeypatch.setenv("AUTH_REQUIRED", "1") + monkeypatch.setenv("JWT_SECRET_KEY", STRONG_SECRET) + monkeypatch.delenv("ADMIN_INITIALIZED", raising=False) + reset_settings_cache() + load_route_policies.cache_clear() + app.dependency_overrides.pop(get_current_user, None) + yield + app.dependency_overrides.pop(get_current_user, None) + reset_settings_cache() + load_route_policies.cache_clear() + + +def _matrix_rows() -> list[dict[str, str]]: + with MATRIX_PATH.open("r", encoding="utf-8-sig", newline="") as handle: + return list(csv.DictReader(handle)) + + +def _user(role: str) -> CurrentUser: + permissions = sorted(ROLE_PERMISSIONS[role]) + return CurrentUser( + user_id=f"{role}-id", + username=role, + role=role, + permissions=permissions, + auth_mode="dependency_override", + ) + + +def _probe_client(user: CurrentUser | None = None) -> TestClient: + probe = FastAPI() + + @probe.api_route("/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) + async def allowed_probe(path: str) -> dict[str, str]: + return {"path": path} + + probe.add_middleware(ApiSecurityMiddleware) + if user is not None: + probe.dependency_overrides[get_current_user] = lambda: user + return TestClient(probe) + + +def test_permission_matrix_exactly_covers_runtime_routes_and_is_reproducible(): + rows = _matrix_rows() + matrix_keys = {(row["Method"], row["Path"]) for row in rows} + runtime_keys = app_route_keys(app) + + assert len(rows) == 208 + assert len({row["Path"] for row in rows}) == 192 + assert matrix_keys == runtime_keys + assert rows == build_rows() + validate_app_route_coverage(app) + + +def test_unclassified_new_route_fails_static_gate(): + probe = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) + + @probe.get("/api/day3-unclassified") + def unclassified() -> dict[str, bool]: + return {"ok": True} + + with pytest.raises(RuntimeError, match="missing=.*day3-unclassified"): + validate_app_route_coverage(probe) + + +def test_public_whitelist_and_all_protected_or_write_routes_are_explicit(): + rows = _matrix_rows() + assert all("Route Name" in row and "Response Model" in row for row in rows) + public = {(row["Method"], row["Path"]) for row in rows if row["Anonymous"] == "yes"} + assert public == PUBLIC_ROUTE_KEYS + for row in rows: + if row["Anonymous"] == "no": + assert row["Required Permission"] + assert row["Target Guard"] == f"authenticated+permission:{row['Required Permission']}" + if row["Write Operation"] == "yes" and row["Anonymous"] == "no": + assert row["Required Permission"] + assert row["Allowed Roles"] != "anonymous" + + +@pytest.mark.parametrize( + ("role", "method", "path", "expected"), + [ + ("viewer", "GET", "/api/dashboard/overview", 200), + ("analyst", "GET", "/api/dashboard/overview", 200), + ("viewer", "GET", "/api/forecast/latest", 200), + ("viewer", "POST", "/api/forecast/run", 403), + ("analyst", "POST", "/api/forecast/run", 200), + ("viewer", "GET", "/api/reports/latest", 200), + ("viewer", "POST", "/api/reports/generate", 403), + ("analyst", "POST", "/api/reports/generate", 200), + ("viewer", "GET", "/api/strategy/latest", 200), + ("analyst", "POST", "/api/strategies/strategy-1/approve", 403), + ("viewer", "POST", "/api/strategies/strategy-1/approve", 403), + ("admin", "POST", "/api/strategies/strategy-1/approve", 200), + ("viewer", "POST", "/api/models/center/activate", 403), + ("analyst", "POST", "/api/models/center/activate", 403), + ("admin", "POST", "/api/models/center/activate", 200), + ("viewer", "POST", "/api/models/center/training/start", 403), + ("analyst", "POST", "/api/models/center/training/start", 403), + ("admin", "POST", "/api/models/center/training/start", 200), + ("viewer", "POST", "/api/models/center/rollback", 403), + ("analyst", "POST", "/api/models/center/rollback", 403), + ("admin", "POST", "/api/models/center/rollback", 200), + ("viewer", "POST", "/api/tasks/task-1/retry", 403), + ("analyst", "POST", "/api/tasks/task-1/retry", 403), + ("admin", "POST", "/api/tasks/task-1/retry", 200), + ("viewer", "GET", "/api/users", 403), + ("analyst", "GET", "/api/users", 403), + ("admin", "GET", "/api/users", 200), + ("viewer", "GET", "/api/audit/logs", 403), + ("analyst", "GET", "/api/audit/logs", 403), + ("admin", "GET", "/api/audit/logs", 200), + ("viewer", "PUT", "/api/settings/runtime-config", 403), + ("analyst", "PUT", "/api/settings/runtime-config", 403), + ("admin", "PUT", "/api/settings/runtime-config", 200), + ], +) +def test_role_permission_matrix(role, method, path, expected): + response = _probe_client(_user(role)).request(method, path, json={}) + assert response.status_code == expected + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("GET", "/api/dashboard/overview"), + ("GET", "/api/forecast/latest"), + ("GET", "/api/reports/latest"), + ("GET", "/api/strategy/latest"), + ("POST", "/api/ai/chat/stream"), + ("GET", "/api/knowledge/documents"), + ("GET", "/api/models/active"), + ("GET", "/api/tasks"), + ("GET", "/api/settings/config"), + ("GET", "/api/users"), + ("GET", "/api/data/datasets"), + ], +) +def test_anonymous_users_cannot_access_business_domains(method, path): + response = _probe_client().request(method, path, json={} if method != "GET" else None) + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert response.json() == {"detail": "缺少登录令牌"} + + +def test_401_contract_invalid_expired_incomplete_and_disabled_users(monkeypatch): + client = TestClient(app) + valid_record = {"user_id": "viewer-id", "username": "viewer", "role": "viewer", "is_active": True} + monkeypatch.setattr(security, "get_user_by_username", lambda username: valid_record) + expired = create_access_token( + subject="viewer", + user_id="viewer-id", + role="viewer", + permissions=[], + extra={"exp": int(time.time()) - 1}, + ) + incomplete = create_access_token(subject="viewer", user_id="viewer-id", role="viewer", extra={"sub": ""}) + cases = ["broken-token", expired, incomplete] + for token in cases: + response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert set(response.json()) == {"detail"} + assert "Traceback" not in response.text + + disabled = {**valid_record, "is_active": False} + monkeypatch.setattr(security, "get_user_by_username", lambda username: disabled) + token = create_access_token(subject="viewer", user_id="viewer-id", role="viewer", permissions=[]) + response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + + +def test_empty_token_permissions_use_current_role_and_403_is_distinct(monkeypatch): + monkeypatch.setattr( + security, + "get_user_by_username", + lambda username: {"user_id": "viewer-id", "username": username, "role": "viewer", "is_active": True}, + ) + token = create_access_token(subject="viewer", user_id="viewer-id", role="viewer", permissions=[]) + client = TestClient(app) + me = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + denied = client.post("/api/reports/generate", headers={"Authorization": f"Bearer {token}"}, json={}) + assert me.status_code == 200 + assert "report:generate" not in me.json()["user"]["permissions"] + assert denied.status_code == 403 + assert "www-authenticate" not in denied.headers + assert denied.json() == {"detail": "缺少权限:report:generate"} + + +def test_unknown_development_role_has_no_viewer_fallback(monkeypatch): + monkeypatch.setenv("AUTH_REQUIRED", "0") + reset_settings_cache() + client = TestClient(app) + response = client.get( + "/api/dashboard/overview", + headers={"X-User": "unknown-role-user", "X-Role": "unknown-role"}, + ) + assert response.status_code == 403 + assert security._permissions_for_role("unknown-role") == [] + + +def test_cors_headers_are_preserved_on_authentication_errors(): + response = TestClient(app).get( + "/api/auth/me", + headers={"Origin": "http://localhost:5173"}, + ) + assert response.status_code == 401 + assert response.headers["access-control-allow-origin"] == "http://localhost:5173" + + +def test_download_stream_and_compatibility_aliases_have_no_bypass(): + rows = _matrix_rows() + by_key = {(row["Method"], row["Path"]): row for row in rows} + aliases = [ + ("/api/model/backtest/summary", "/api/models/backtest/summary"), + ("/api/model/feature-schema", "/api/models/feature-schema"), + ("/api/model/leakage-check", "/api/models/leakage-check"), + ] + for left, right in aliases: + assert by_key[("GET", left)]["Required Permission"] == by_key[("GET", right)]["Required Permission"] + assert "compatibility alias" in by_key[("GET", left)]["Notes"] + assert "compatibility alias" in by_key[("GET", right)]["Notes"] + + anonymous = TestClient(app) + for method, path in [ + ("GET", "/api/reports/missing/download"), + ("POST", "/api/ai/chat/stream"), + ("GET", "/api/model/feature-schema"), + ("GET", "/api/models/feature-schema"), + ]: + assert anonymous.request(method, path, json={} if method == "POST" else None).status_code == 401 + + analyst = _user("analyst") + app.dependency_overrides[get_current_user] = lambda: analyst + assert TestClient(app).get("/api/reports/missing/download").status_code == 404 + assert _probe_client(analyst).post("/api/ai/chat/stream", json={}).status_code == 200 + + +def test_production_disables_documentation_routes(monkeypatch): + monkeypatch.setenv("APP_ENV", "production") + monkeypatch.setenv("AUTH_REQUIRED", "1") + monkeypatch.setenv("JWT_SECRET_KEY", STRONG_SECRET) + monkeypatch.setenv("ADMIN_INITIALIZED", "1") + monkeypatch.setenv("DATABASE_URL", "postgresql+psycopg://runtime:placeholder@localhost:5432/postgres") + monkeypatch.setenv("SECURITY_DATABASE_URL", "postgresql+psycopg://security:placeholder@localhost:5432/postgres") + reset_settings_cache() + production_app = create_app() + validate_app_route_coverage(production_app) + assert DEVELOPMENT_DOC_ROUTE_KEYS.isdisjoint(app_route_keys(production_app)) + client = TestClient(production_app) + assert client.get("/docs").status_code == 404 + assert client.get("/openapi.json").status_code == 404 + + +def test_frontend_auth_and_permission_contracts(): + auth_context = (ROOT / "frontend/src/context/AuthContext.tsx").read_text(encoding="utf-8") + api_source = (ROOT / "frontend/src/api.ts").read_text(encoding="utf-8") + report_page = (ROOT / "frontend/src/pages/report/ReportCenterPage.tsx").read_text(encoding="utf-8") + task_page = (ROOT / "frontend/src/pages/task/TaskCenterPage.tsx").read_text(encoding="utf-8") + model_page = (ROOT / "frontend/src/pages/model/ModelCenterPage.tsx").read_text(encoding="utf-8") + view_state = (ROOT / "frontend/src/services/viewState.ts").read_text(encoding="utf-8") + + assert "VITE_AUTH_REQUIRED ?? '1'" in auth_context + assert "auth:unauthorized" in api_source + assert "requestDownload(`/api/reports/${encodeURIComponent(reportId)}/download`)" in api_source + assert "window.open(api.reportDownloadUrl" not in report_page + assert "report.generate" in report_page and "report:review" in report_page + assert "task:run" in task_page + assert "model:manage" in model_page + assert "descriptor.status === 403" in view_state and "state: 'forbidden'" in view_state diff --git a/tests/test_enterprise_memory_core.py b/tests/test_enterprise_memory_core.py index 76001fc..6e443f2 100644 --- a/tests/test_enterprise_memory_core.py +++ b/tests/test_enterprise_memory_core.py @@ -124,7 +124,8 @@ def memory_table_names() -> tuple[str, ...]: def test_enterprise_memory_live_transaction_scope_and_idempotency(monkeypatch: pytest.MonkeyPatch) -> None: assert get_settings().has_database_url is True with get_engine().connect() as probe: - assert probe.execute(text("SELECT current_schema()" )).scalar_one() == os.environ["DAY4_TARGET_SCHEMA"] + expected_schema = os.environ.get("DAY4_TARGET_SCHEMA") or os.environ["BETA10D_TEST_SCHEMA"] + assert probe.execute(text("SELECT current_schema()" )).scalar_one() == expected_schema first_session = _identity(session="session_first") second_session = _identity(session="session_second") other_user = _identity(user="user_b", session="session_other_user") diff --git a/tests/test_enterprise_memory_lifecycle.py b/tests/test_enterprise_memory_lifecycle.py index a4e219f..e6cf1c9 100644 --- a/tests/test_enterprise_memory_lifecycle.py +++ b/tests/test_enterprise_memory_lifecycle.py @@ -11,6 +11,7 @@ from backend.app.ai_assistant.memory import enterprise_memory as core from backend.app.ai_assistant.memory import lifecycle from backend.app.db.session import get_engine +from scripts import day5_memory_worker_runtime as memory_worker def identity(user: str = "user_a", *, tenant: str = "tenant_a", admin: bool = False) -> IdentityContext: @@ -155,3 +156,34 @@ def test_legal_hold_admin_scope_archive_and_retention() -> None: lifecycle.process_outbox_batch() with core._engine().connect() as connection: assert connection.execute(text("SELECT status FROM ai_memory_deletion_jobs WHERE job_id=:job"), {"job": late_request["job_id"]}).scalar_one() == "completed" + + +@pytest.mark.skipif(os.environ.get("DAY5_MEMORY_LIVE_TEST") != "1", reason="isolated PostgreSQL gate only") +def test_memory_worker_once_executes_real_deletion_batch(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + owner = identity(user="worker_user") + created = core.admit_memory(owner, content="worker once deletion record", summary="worker deletion") + requested = lifecycle.request_deletion(owner, created["memory_id"]) + runtime = tmp_path / "runtime" + heartbeat = runtime / "heartbeat.json" + monkeypatch.setattr(memory_worker, "paths", lambda: { + "runtime": runtime, + "pid": runtime / "worker.json", + "heartbeat": heartbeat, + "log": tmp_path / "worker.log", + }) + + report = memory_worker.run_once() + + assert report["ok"] is True + assert report["schema_contract_ready"] is True + assert any(item["status"] == "processed" for item in report["last_batch"]) + assert heartbeat.is_file() + with core._engine().connect() as connection: + assert connection.execute( + text("SELECT status FROM ai_memory_deletion_jobs WHERE job_id=:job_id"), + {"job_id": requested["job_id"]}, + ).scalar_one() == "completed" + assert connection.execute( + text("SELECT count(*) FROM ai_memory_records WHERE memory_id=:memory_id"), + {"memory_id": created["memory_id"]}, + ).scalar_one() == 0 diff --git a/tests/test_model_center_business_api.py b/tests/test_model_center_business_api.py new file mode 100644 index 0000000..03c3ede --- /dev/null +++ b/tests/test_model_center_business_api.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from backend.app.api.v1.endpoints import model as model_endpoint +from backend.app.main import app + + +client = TestClient(app) + + +def test_model_center_overview_endpoint_contract(monkeypatch): + def fake_overview(**_: object) -> dict: + return { + "available": True, + "active": {"model_version": "v3.2.1", "status": "Active"}, + "candidate": {"model_version": "v3.3.0-rc1", "status": "Candidate"}, + "versions": [{"model_version": "v3.2.1"}, {"model_version": "v3.3.0-rc1"}], + "effect": [{"time": "06-21 00:00", "actual": 100, "active": 102, "candidate": 101, "diff": -1, "data_origin": "seed"}], + "error_trend": [{"date": "06-21", "mae": 15.42, "rmse": 23.67, "mape": 7.21}], + "admission": {"rules": [], "passed": True, "conclusion": "满足准入条件"}, + "evaluation_summary": [], + "training": {"task_id": "train_1", "status": "success"}, + "rollback": {"options": []}, + "events": [], + "data_lineage": {"effect": "postgresql.model_prediction_comparison_points"}, + } + + monkeypatch.setattr(model_endpoint, "model_center_overview", fake_overview) + response = client.get("/api/models/center/overview") + assert response.status_code == 200 + payload = response.json() + assert payload["active"]["model_version"] == "v3.2.1" + assert payload["candidate"]["status"] == "Candidate" + assert payload["effect"] + assert payload["data_lineage"]["effect"] == "postgresql.model_prediction_comparison_points" + + +def test_model_center_version_detail_endpoint(monkeypatch): + monkeypatch.setattr( + model_endpoint, + "model_version_detail", + lambda version, **_: { + "available": True, + "version": {"model_version": version, "status": "Candidate"}, + "latest_metric": {"mae": 15.42}, + "metrics_history": [{"metric_date": "2025-06-21", "mae": 15.42}], + "events": [{"event_id": "evt_1", "action": "model.activate"}], + "prediction_samples": [{"time": "06-21 00:00", "actual_value": 100}], + }, + ) + response = client.get("/api/models/center/versions/v3.3.0-rc1") + assert response.status_code == 200 + payload = response.json() + assert payload["available"] is True + assert payload["version"]["model_version"] == "v3.3.0-rc1" + assert payload["metrics_history"] + + +def test_model_center_training_start_endpoint(monkeypatch): + monkeypatch.setattr(model_endpoint, "start_model_training", lambda user, payload: {"task_id": "train_x", "status": "queued"}) + monkeypatch.setattr(model_endpoint, "write_audit_log", lambda **_: True) + response = client.post("/api/models/center/training/start", json={"model_type": "负荷预测模型", "region": "浙江省"}) + assert response.status_code == 200 + assert response.json()["task_id"] == "train_x" + + +def test_model_center_training_start_returns_503_without_worker(monkeypatch): + monkeypatch.setattr( + model_endpoint, + "start_model_training", + lambda user, payload: (_ for _ in ()).throw(RuntimeError("forecast worker unavailable")), + ) + response = client.post( + "/api/models/center/training/start", + json={"model_type": "负荷预测模型", "region": "浙江省"}, + ) + assert response.status_code == 503 + assert response.json()["detail"] == "forecast worker unavailable" + + +def test_model_center_activate_endpoint(monkeypatch): + monkeypatch.setattr( + model_endpoint, + "activate_model_version", + lambda version, user, reason, **_: {"success": True, "active_version": version}, + ) + monkeypatch.setattr(model_endpoint, "write_audit_log", lambda **_: True) + response = client.post("/api/models/center/activate", json={"version": "v3.3.0-rc1", "reason": "test"}) + assert response.status_code == 200 + assert response.json()["active_version"] == "v3.3.0-rc1" + + +def test_model_center_activate_failure_is_not_http_200(monkeypatch): + monkeypatch.setattr( + model_endpoint, + "activate_model_version", + lambda *args, **kwargs: {"success": False, "message": "candidate not validated"}, + ) + monkeypatch.setattr(model_endpoint, "write_audit_log", lambda **_: True) + response = client.post( + "/api/models/center/activate", + json={"version": "candidate-invalid", "reason": "acceptance"}, + ) + assert response.status_code == 409 + assert response.json()["detail"]["success"] is False + + +def test_model_center_rollback_endpoint(monkeypatch): + monkeypatch.setattr( + model_endpoint, + "rollback_model_version", + lambda version, user, reason, **_: { + "success": True, + "active_version": version, + "previous_active_version": "v3.3.0", + }, + ) + monkeypatch.setattr(model_endpoint, "write_audit_log", lambda **_: True) + response = client.post( + "/api/models/center/rollback", + json={"version": "v3.2.1", "reason": "rollback acceptance"}, + ) + assert response.status_code == 200 + assert response.json()["active_version"] == "v3.2.1" diff --git a/tests/test_model_center_frontend_contract.py b/tests/test_model_center_frontend_contract.py new file mode 100644 index 0000000..22df29a --- /dev/null +++ b/tests/test_model_center_frontend_contract.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PAGE = ROOT / "frontend" / "src" / "pages" / "model" / "ModelCenterPage.tsx" +SERVICE = ROOT / "frontend" / "src" / "services" / "modelApi.ts" +API = ROOT / "frontend" / "src" / "api.ts" + + +def test_model_center_page_is_lifecycle_workbench(): + text = PAGE.read_text(encoding="utf-8") + assert "一体化总览" not in text + assert "Active 模型" in text + assert "预测效果对比图" in text + assert "误差趋势" in text + assert "候选模型准入规则" in text + assert "训练状态" in text + assert "回滚操作" in text + assert "查看详情" in text + assert "查看训练日志" in text + assert "治理记录" in text + assert "disabled={!canRunTraining || !rollbackVersion}" in text + assert "disabled={!canRunTraining || !data.rollback.options.length}" in text + assert "训练任务已进入 Worker 队列" in text + assert "chat" not in text.lower() + + +def test_model_center_frontend_uses_backend_api_not_mock(): + page = PAGE.read_text(encoding="utf-8") + service = SERVICE.read_text(encoding="utf-8") + api = API.read_text(encoding="utf-8") + assert "modelMock" not in page + assert "modelMock" not in service + assert "getModelCenterData" in page + assert "modelCenterOverview" in service + assert "getModelVersionDetail" in service + assert "getModelTrainingLogs" in service + assert "/api/models/center/overview" in api + assert "/api/models/center/versions/" in api + assert "/api/models/center/training/start" in api + assert "/api/models/center/rollback" in api + assert "/api/tasks/" in api diff --git a/tests/test_model_training_execution.py b/tests/test_model_training_execution.py new file mode 100644 index 0000000..640300e --- /dev/null +++ b/tests/test_model_training_execution.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import argparse +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +import main_daily_run +from backend.app.core.security import CurrentUser +from backend.app.repositories import model_repository +from backend.app.services.model_fact_service import ModelFactError +from backend.app.workers import dispatcher, tasks +from model_ops import model_registry +from services import prediction_service + + +def _args(**overrides) -> argparse.Namespace: + values = { + "model_auto_optimize": False, + "retrain_model": False, + "refresh_data": False, + "fast_forecast": False, + "skip_prediction": False, + "prediction_report_only": False, + } + values.update(overrides) + return argparse.Namespace(**values) + + +def test_explicit_retrain_runs_training_only_without_report_dispatch() -> None: + assert main_daily_run.build_sequence(_args(retrain_model=True)) == [ + "01_run_prediction.py" + ] + + +def _prediction_paths(tmp_path: Path) -> SimpleNamespace: + engine_script = tmp_path / "engine.py" + engine_script.write_text("# controlled test engine", encoding="utf-8") + result_dir = tmp_path / "results" + result_dir.mkdir() + data_dir = tmp_path / "data" + data_dir.mkdir() + return SimpleNamespace( + root_dir=tmp_path, + engine_script=engine_script, + result_table_dir=result_dir, + data_dir=data_dir, + ) + + +def _patch_prediction_dependencies(monkeypatch, paths, events: list[tuple]): + monkeypatch.setattr(prediction_service, "get_pipeline_paths", lambda _config: paths) + monkeypatch.setattr( + prediction_service, + "run_prediction_engine", + lambda *args, **kwargs: SimpleNamespace(returncode=0), + ) + monkeypatch.setattr( + prediction_service, + "_export_retrain_inputs_from_postgres", + lambda *args, **kwargs: {"master_rows": 8760}, + ) + monkeypatch.setattr( + prediction_service, "sync_result_tables_to_database", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + prediction_service, + "save_pipeline_event", + lambda *args, **kwargs: events.append(args), + ) + + +def test_explicit_retrain_fails_when_expected_candidate_artifact_is_missing( + monkeypatch, tmp_path: Path +) -> None: + paths = _prediction_paths(tmp_path) + events: list[tuple] = [] + _patch_prediction_dependencies(monkeypatch, paths, events) + + with pytest.raises(RuntimeError, match="未生成预期 artifact"): + prediction_service.run_prediction( + config={}, + run_context={"run_id": "run_training_missing", "run_mode": "retrain_model"}, + log=lambda _message: None, + ) + + assert any(item[1] == "candidate_registration" and item[2] == "failed" for item in events) + + +def test_explicit_retrain_requires_successful_candidate_registration( + monkeypatch, tmp_path: Path +) -> None: + paths = _prediction_paths(tmp_path) + artifact_dir = paths.root_dir / "model_artifacts" / "model_run_training_ok" + events: list[tuple] = [] + _patch_prediction_dependencies(monkeypatch, paths, events) + monkeypatch.setattr( + prediction_service, + "run_prediction_engine", + lambda *args, **kwargs: ( + artifact_dir.mkdir(parents=True), + SimpleNamespace(returncode=0), + )[1], + ) + monkeypatch.setattr( + prediction_service, + "register_model_artifact", + lambda *args, **kwargs: "model_run_training_ok", + ) + monkeypatch.setattr(prediction_service, "register_strategy_result", lambda *args, **kwargs: None) + + result = prediction_service.run_prediction( + config={}, + run_context={"run_id": "run_training_ok", "run_mode": "retrain_model"}, + log=lambda _message: None, + ) + + assert result.returncode == 0 + assert any(item[1] == "candidate_registration" and item[2] == "completed" for item in events) + + +def test_model_center_start_uses_real_dispatcher_instead_of_manual_pending( + monkeypatch, +) -> None: + monkeypatch.setattr( + dispatcher, + "enqueue_task", + lambda kind, payload: { + "task_id": "task_training_real", + "run_id": "run_training_real", + "status": "queued", + "kind": kind, + "payload": payload, + "queue_name": "forecast_final_rc", + "celery_task_id": "celery-training-real", + }, + ) + monkeypatch.setattr( + model_repository, + "record_model_event", + lambda **kwargs: {"available": True, "event_id": "event-training-real"}, + ) + user = CurrentUser( + user_id="admin-1", + username="admin", + role="admin", + permissions=["model:manage"], + auth_mode="unit_test", + ) + + result = model_repository.start_model_training( + user=user, + payload={"reason": "controlled training"}, + ) + + assert result["status"] == "queued" + assert result["kind"] == "retrain_model" + assert result["celery_task_id"] == "celery-training-real" + assert result["event_recorded"] is True + + +def test_command_worker_propagates_task_run_identity(monkeypatch, tmp_path: Path) -> None: + captured: dict = {} + saved_records: list[dict] = [] + + class FakeProcess: + returncode = 0 + + def poll(self): + return 0 + + def fake_popen(*args, **kwargs): + captured["env"] = kwargs["env"] + return FakeProcess() + + monkeypatch.setattr(tasks, "command_for_kind", lambda _kind: ["python", "training.py"]) + monkeypatch.setattr(tasks, "project_paths", lambda: SimpleNamespace(log_dir=tmp_path)) + monkeypatch.setattr( + tasks, + "save_task_record", + lambda record, *args, **kwargs: saved_records.append(record) or True, + ) + monkeypatch.setattr(tasks, "append_task_log", lambda *args, **kwargs: True) + monkeypatch.setattr( + tasks, + "get_task_record", + lambda _task_id: { + "timeout_seconds": 60, + "payload": {"rolling_backtest": False, "reason": "unit"}, + "queue_name": "forecast_final_rc", + "celery_task_id": "celery-training-identity", + }, + ) + monkeypatch.setattr(tasks.subprocess, "Popen", fake_popen) + + result = tasks.run_command_task( + "retrain_model", "task_training_identity", "run_training_identity" + ) + + assert result["status"] == "success" + assert captured["env"]["PIPELINE_RUN_ID"] == "run_training_identity" + assert captured["env"]["PIPELINE_TASK_ID"] == "task_training_identity" + assert captured["env"]["ENABLE_ROLLING_BACKTEST"] == "0" + assert saved_records[0]["payload"]["reason"] == "unit" + assert saved_records[-1]["payload"]["rolling_backtest"] is False + assert result["queue_name"] == "forecast_final_rc" + assert result["celery_task_id"] == "celery-training-identity" + assert all(record["queue_name"] == "forecast_final_rc" for record in saved_records) + assert all(record["celery_task_id"] == "celery-training-identity" for record in saved_records) + + +def test_dispatcher_generates_collision_resistant_run_identity(monkeypatch) -> None: + generated = dispatcher.generate_run_id() + assert generated.startswith("run_") + assert len(generated) > 30 + + +def test_retrain_workspace_is_unique_and_run_scoped() -> None: + config = {"paths": {}} + root = main_daily_run.configure_retrain_workspace(config, "run_contract_20260830") + + assert root.name == "run_contract_20260830" + assert config["paths"]["data_dir"] == str(root / "input") + assert config["paths"]["result_table_dir"] == str(root / "results" / "结果表") + + with pytest.raises(SystemExit, match="非法字符"): + main_daily_run.configure_retrain_workspace({"paths": {}}, "../escape") + + +def test_training_database_identity_is_required_before_connection(monkeypatch) -> None: + monkeypatch.delenv(model_registry.MODEL_TRAINING_DATABASE_URL_ENV, raising=False) + with pytest.raises(ModelFactError, match="缺少独立"): + model_registry.create_model_training_engine() + + monkeypatch.setenv( + model_registry.MODEL_TRAINING_DATABASE_URL_ENV, + "postgresql+psycopg://beta10d_app_login:masked@localhost:5432/postgres", + ) + with pytest.raises(ModelFactError, match="受限身份"): + model_registry.create_model_training_engine() + + +def test_artifact_hash_manifest_is_static_and_fail_closed(tmp_path: Path) -> None: + artifact = tmp_path / "model_run_hash" + artifact.mkdir() + (artifact / "manifest.json").write_text('{"model_version":"model_run_hash"}', encoding="utf-8") + (artifact / "base_model.joblib").write_bytes(b"newly-trained-model-bytes") + + first_hash = model_registry.write_artifact_hash_manifest(artifact) + second_hash = model_registry.write_artifact_hash_manifest(artifact) + assert first_hash == second_hash + assert len(first_hash) == 64 + + (artifact / "base_model.joblib").write_bytes(b"changed") + with pytest.raises(ModelFactError, match="不一致"): + model_registry.write_artifact_hash_manifest(artifact) + + +def test_candidate_registration_rejects_stale_declared_artifact_hash( + monkeypatch, tmp_path: Path +) -> None: + artifact = tmp_path / "model_run_declared_hash" + artifact.mkdir() + (artifact / "manifest.json").write_text( + '{"model_version":"model_run_declared_hash","artifact_hash":"stale"}', + encoding="utf-8", + ) + (artifact / "metrics.json").write_text("{}", encoding="utf-8") + (artifact / "base_model.joblib").write_bytes(b"newly-trained-model-bytes") + + with pytest.raises(ModelFactError, match="静态文件校验结果不一致"): + model_registry.register_model_artifact({}, artifact) + + +def _load_training_engine(monkeypatch, tmp_path: Path): + engine_path = Path(__file__).resolve().parents[1] / "高峰尖刺增强版_电力市场电价预测与智能分析系统_v4_fix1.py" + monkeypatch.setenv("PIPELINE_RESULT_DIR", str(tmp_path / "engine-results")) + spec = importlib.util.spec_from_file_location("training_engine_under_test", engine_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_forward_forecast_inherits_error_memory_without_nan_fill(monkeypatch, tmp_path: Path) -> None: + engine = _load_training_engine(monkeypatch, tmp_path) + history_index = pd.date_range("2026-01-01", periods=240, freq="h") + history = pd.DataFrame( + { + "datetime": history_index, + "da_price": np.linspace(40.0, 80.0, len(history_index)), + "forecast_load": np.linspace(1000.0, 1100.0, len(history_index)), + "actual_load": np.linspace(995.0, 1095.0, len(history_index)), + "temperature": 20.0, + "wind_speed": 5.0, + "precipitation": 0.0, + "price_spread_rt_minus_da": 1.0, + } + ) + history = engine.run_full_feature_engineering(history) + history.loc[history.index[-1], "hour_bias_mean"] = 1.25 + history.loc[history.index[-1], "scenario_mae"] = 2.5 + history.loc[history.index[-1], "historical_under_predict_rate"] = 0.4 + + target_index = pd.date_range(history["datetime"].max() + pd.Timedelta(hours=1), periods=24, freq="h") + future_base = pd.DataFrame( + { + "datetime": target_index, + "forecast_load": 1105.0, + "actual_load": 1100.0, + "temperature": 21.0, + "wind_speed": 4.0, + "precipitation": 0.0, + "price_spread_rt_minus_da": 1.0, + "forecast_load_source": "controlled-test", + "future_weather_source": "controlled-test", + "actual_load_proxy_source": "controlled-test", + "rt_spread_proxy_source": "controlled-test", + } + ) + monkeypatch.setattr(engine, "build_formal_forward_base_frame", lambda *_args: future_base.copy()) + + class ConstantRegressor: + def predict(self, frame): + return np.full(len(frame), 60.0) + + class ConstantClassifier: + def predict_proba(self, frame): + return np.column_stack([np.full(len(frame), 0.8), np.full(len(frame), 0.2)]) + + features = ["hour", "hour_bias_mean", "scenario_mae", "historical_under_predict_rate"] + artifacts = { + "final_base_model": ConstantRegressor(), + "final_peak_model": ConstantRegressor(), + "final_classifier": ConstantClassifier(), + "final_p90_model": None, + "best_alpha": 0.0, + "best_floor": 0.0, + "best_spike_threshold": 0.5, + } + + result, snapshot = engine.generate_formal_forward_forecast(history, features, artifacts, history) + + assert len(result) == 24 + assert len(snapshot) == 24 + assert result["剩余缺失特征数量"].sum() == 0 + assert snapshot[features].notna().all().all() + assert snapshot.iloc[0]["hour_bias_mean"] == pytest.approx(1.25) + assert snapshot.iloc[0]["scenario_mae"] == pytest.approx(2.5) + assert snapshot.iloc[0]["historical_under_predict_rate"] == pytest.approx(0.4) diff --git a/tests/test_p4_task_health.py b/tests/test_p4_task_health.py new file mode 100644 index 0000000..0b853fc --- /dev/null +++ b/tests/test_p4_task_health.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from backend.app.core import config +from backend.app.workers import dispatcher + + +def test_task_runtime_health_contains_queue_and_worker_state(monkeypatch): + monkeypatch.setenv("TASK_EXECUTION_MODE", "celery") + config.reset_settings_cache() + monkeypatch.setattr(dispatcher, "redis_available", lambda: True) + monkeypatch.setattr(dispatcher, "celery_available", lambda: True) + monkeypatch.setattr( + dispatcher, + "_celery_runtime_snapshot", + lambda: { + "available": True, + "source": "celery_inspect", + "active_workers": ["worker-1"], + "registered_tasks": ["power_trading.run_price_predict_task"], + "active_count": 0, + "reserved_count": 0, + "scheduled_count": 0, + "active_queues": [ + "default", + "data_sync", + "embedding", + "price_predict", + "rag", + "report", + "report_daily", + ], + }, + ) + monkeypatch.setattr( + dispatcher, + "task_runtime_summary", + lambda: { + "active_workers": ["worker-1"], + "queue_summary": [{"queue_name": "embedding", "pending": 1, "running": 2, "failed": 0, "timeout": 0}], + "status_counts": {"running": 2, "pending": 1, "failed": 1, "timeout": 1}, + "running_task_count": 2, + "pending_task_count": 1, + "failed_task_count": 1, + "timeout_task_count": 1, + }, + ) + try: + health = dispatcher.task_runtime_health() + finally: + monkeypatch.delenv("TASK_EXECUTION_MODE", raising=False) + config.reset_settings_cache() + + assert health["ok"] is True + assert health["dispatch_ready"] is True + assert health["execution_mode"] == "celery" + assert health["redis"]["ok"] is True + assert health["celery"]["ok"] is True + assert health["active_workers"] == ["worker-1"] + assert health["queue_summary"][0]["queue_name"] == "embedding" + assert health["running_task_count"] == 2 + assert health["pending_task_count"] == 1 + assert health["celery"]["missing_queues"] == [] + + +def test_auto_health_fails_when_a_required_business_queue_is_missing(monkeypatch): + monkeypatch.setenv("TASK_EXECUTION_MODE", "auto") + config.reset_settings_cache() + monkeypatch.setattr(dispatcher, "redis_available", lambda: True) + monkeypatch.setattr(dispatcher, "celery_available", lambda: True) + monkeypatch.setattr( + dispatcher, + "_celery_runtime_snapshot", + lambda: { + "available": True, + "source": "celery_inspect", + "active_workers": ["health-worker"], + "active_queues": ["default"], + }, + ) + monkeypatch.setattr(dispatcher, "task_runtime_summary", lambda: {}) + try: + health = dispatcher.task_runtime_health() + finally: + monkeypatch.delenv("TASK_EXECUTION_MODE", raising=False) + config.reset_settings_cache() + + assert health["ok"] is False + assert health["dispatch_ready"] is False + assert "default" not in health["celery"]["missing_queues"] + assert "data_sync" in health["celery"]["missing_queues"] + assert "required Celery queues are unavailable" in health["message"] + + +def test_auto_health_is_unavailable_without_live_worker_and_ignores_db_snapshot(monkeypatch): + monkeypatch.setenv("TASK_EXECUTION_MODE", "auto") + config.reset_settings_cache() + monkeypatch.setattr(dispatcher, "redis_available", lambda: True) + monkeypatch.setattr(dispatcher, "celery_available", lambda: True) + monkeypatch.setattr( + dispatcher, + "_celery_runtime_snapshot", + lambda: {"available": False, "source": "celery_inspect", "active_workers": []}, + ) + monkeypatch.setattr( + dispatcher, + "task_runtime_summary", + lambda: {"active_workers": ["stale-db-worker"], "pending_task_count": 2}, + ) + try: + health = dispatcher.task_runtime_health() + finally: + monkeypatch.delenv("TASK_EXECUTION_MODE", raising=False) + config.reset_settings_cache() + + assert health["ok"] is False + assert health["dispatch_ready"] is False + assert health["active_workers"] == [] + assert health["celery"]["db_snapshot_workers"] == ["stale-db-worker"] + assert "no active Celery worker" in health["message"] diff --git a/tests/test_p6_p0_2_frontend_contract.py b/tests/test_p6_p0_2_frontend_contract.py new file mode 100644 index 0000000..b391a06 --- /dev/null +++ b/tests/test_p6_p0_2_frontend_contract.py @@ -0,0 +1,178 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +FRONTEND = ROOT / "frontend" / "src" + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def test_unified_header_is_the_only_data_forecast_header(): + header = _read("frontend/src/components/common/PageHeader.tsx") + data_page = _read("frontend/src/pages/data/DataCenterPage.tsx") + forecast_page = _read("frontend/src/pages/forecast/ForecastCenterPage.tsx") + data_design = _read("frontend/src/components/data/DataCenterDesign.tsx") + forecast_design = _read("frontend/src/components/forecast/ForecastDesign.tsx") + styles = _read("frontend/src/styles.css") + + for contract in ["navigation", "metadata", "filters", "actions", "collapseAtNarrow", "disabledReason"]: + assert contract in header + assert "page-heading-more" in header + assert "@container (max-width: 1150px)" in styles + assert "PageHeader" in data_page and "PageHeader" in forecast_page + # Current page routing is owned by the global navigation; both pages still + # share the single PageHeader contract and must not grow a second header. + assert "PageTabs" not in data_design and "PageTabs" not in forecast_design + assert "DataPageHeader" not in data_design + assert "ForecastPageHeader" not in forecast_design + + +def test_view_state_contract_has_seven_distinct_states_and_metadata(): + state = _read("frontend/src/services/viewState.ts") + states = ["loading", "empty", "error", "stale", "success", "unauthorized", "forbidden"] + for name in states: + assert f"'{name}'" in state + for field in [ + "source", + "generatedAt", + "runId", + "modelVersion", + "featureVersion", + "staleReason", + "errorCode", + "errorMessage", + "canRetry", + ]: + assert field in state + + +def test_api_error_preserves_http_status_for_401_and_403_mapping(): + api = _read("frontend/src/api.ts") + states = _read("frontend/src/services/viewState.ts") + assert "export class ApiError extends Error" in api + assert "readonly status: number" in api + assert "throw new ApiError(response.status" in api + assert "descriptor.status === 401" in states + assert "descriptor.status === 403" in states + + +def test_refresh_and_permission_actions_are_real_or_explicitly_disabled(): + data = _read("frontend/src/pages/data/DataCenterPage.tsx") + forecast = _read("frontend/src/pages/forecast/ForecastCenterPage.tsx") + policy = _read("frontend/src/security/permissions.ts") + for source in [data, forecast]: + assert "onClick: loadData" in source + assert "loading," in source + assert "api.dataRefresh()" in data + assert "canPerformAction('data:sync')" in data + assert "canPerformAction('data.export')" in data + assert "api.runForecast()" in forecast + assert "const canRunForecast = canPerformAction('forecast.run')" in forecast + assert "const canGenerateStrategy = canPerformAction('strategy.generate')" in forecast + assert "'forecast.run': 'forecast:run'" in policy + assert "'strategy.generate': 'strategy:generate'" in policy + assert "!authRequired || hasPermission" not in forecast + assert "hidden: !canGenerateStrategy" in forecast + assert "#/strategy/strategy-high" in forecast + assert "在完整业务上下文中生成策略" in forecast + + +def test_forecast_static_success_fallbacks_are_removed(): + forecast_service = _read("frontend/src/services/forecastApi.ts") + forecast_page = _read("frontend/src/pages/forecast/ForecastCenterPage.tsx") + forecast_design = _read("frontend/src/components/forecast/ForecastDesign.tsx") + forbidden = ["2025-06-21", "'v3.2.1'", "'浙江省'", "['负荷预测', '新能源出力', '气温变化']"] + combined = forecast_service + forecast_page + forecast_design + for value in forbidden: + assert value not in combined + assert "mockFallback={false}" not in forecast_page + assert "已启用兜底数据" not in _read("frontend/src/components/common/States.tsx") + + +def test_assistant_api_failure_does_not_import_static_success_payload(): + assistant_service = _read("frontend/src/services/assistantApi.ts") + assert "assistantMock" not in assistant_service + assert "error: errorMessage(error)" in assistant_service + assert "conversations: []" in assistant_service + + +def test_no_fact_status_bar_regression_in_global_or_main_pages(): + targets = [ + "frontend/src/layout/BasicLayout.tsx", + "frontend/src/pages/dashboard/DashboardPage.tsx", + "frontend/src/pages/data/DataCenterPage.tsx", + "frontend/src/pages/forecast/ForecastCenterPage.tsx", + "frontend/src/pages/strategy/StrategyCenterPage.tsx", + "frontend/src/pages/assistant/AssistantPage.tsx", + "frontend/src/pages/report/ReportCenterPage.tsx", + "frontend/src/pages/model/ModelCenterPage.tsx", + "frontend/src/pages/knowledge/KnowledgeBasePage.tsx", + "frontend/src/pages/task/TaskCenterPage.tsx", + "frontend/src/pages/settings/SettingsPage.tsx", + ] + for target in targets: + assert "FactStatusBar" not in _read(target) + + +def test_page_header_layout_prevents_horizontal_page_overflow(): + styles = _read("frontend/src/styles.css") + for selector in [ + ".page-heading-unified", + ".page-heading-copy", + ".page-heading-utility", + ".page-heading-filters", + ]: + assert selector in styles + assert "min-width: 0;" in styles + assert "grid-template-columns: minmax(300px, 0.8fr) minmax(0, 1.6fr);" in styles + + +def test_main_pages_use_the_shared_or_approved_workspace_header_path(): + shared_header_pages = [ + "frontend/src/pages/dashboard/DashboardPage.tsx", + "frontend/src/pages/data/DataCenterPage.tsx", + "frontend/src/pages/forecast/ForecastCenterPage.tsx", + "frontend/src/pages/strategy/StrategyCenterPage.tsx", + "frontend/src/pages/model/ModelCenterPage.tsx", + "frontend/src/pages/knowledge/KnowledgeBasePage.tsx", + "frontend/src/pages/task/TaskCenterPage.tsx", + ] + for target in shared_header_pages: + assert "PageHeader" in _read(target), target + assert "report-page-toolbar" in _read("frontend/src/pages/report/ReportCenterPage.tsx") + + report = _read("frontend/src/pages/report/ReportCenterPage.tsx") + assert '
' in report + assert "report-workspace-grid" in report + + app = _read("frontend/src/app/App.tsx") + page_container = _read("frontend/src/layout/PageContainer.tsx") + assert "", 1)[0] + assert "'assistant'" not in hide_expression + assert "'settings'" not in hide_expression + assert "routeState.route === 'report'" in hide_expression + assert "DataPageHeader" not in _read("frontend/src/components/data/DataCenterDesign.tsx") + assert "ForecastPageHeader" not in _read("frontend/src/components/forecast/ForecastDesign.tsx") + assert "StrategyPageHeader" not in _read("frontend/src/components/strategy/StrategyDesign.tsx") + + +def test_migrated_header_actions_keep_permission_and_noop_guards(): + dashboard = _read("frontend/src/pages/dashboard/DashboardPage.tsx") + model = _read("frontend/src/pages/model/ModelCenterPage.tsx") + knowledge = _read("frontend/src/pages/knowledge/KnowledgeBasePage.tsx") + task = _read("frontend/src/pages/task/TaskCenterPage.tsx") + strategy = _read("frontend/src/pages/strategy/StrategyCenterPage.tsx") + data = _read("frontend/src/pages/data/DataCenterPage.tsx") + assert "canPerformAction('report.generate')" in dashboard + assert "model:manage" in model + assert "knowledge:write" in knowledge + assert "task:run" in task + assert "selectedTaskKind === 'all'" in task + assert "请先选择具体任务类型" in task + assert "!canRunTask || !workerReady" in task + assert "value || '未分配'" in task + for source in (dashboard, data, model, task, strategy): + assert "!authRequired" not in source diff --git a/tests/test_p6_p1_3b_strategy_consistency.py b/tests/test_p6_p1_3b_strategy_consistency.py new file mode 100644 index 0000000..e504c16 --- /dev/null +++ b/tests/test_p6_p1_3b_strategy_consistency.py @@ -0,0 +1,69 @@ +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def test_sc006_uses_one_shared_three_page_header_and_layout_system() -> None: + page = _read("frontend/src/pages/strategy/StrategyCenterPage.tsx") + assert len(re.findall(r")", page)) == 1 + assert page.count(" None: + page = _read("frontend/src/pages/strategy/StrategyCenterPage.tsx") + metadata_start = page.index('className="strategy-header-metadata"') + metadata_end = page.index("
", metadata_start) + metadata = page[metadata_start:metadata_end] + assert "strategyDate" in metadata + for field in ("strategyVersion", "modelVersion", "runId", "generatedAt", "sourceType", "staleReason"): + assert field not in metadata + assert "strategy-meta-run" not in metadata + assert "strategy-meta-stale" not in metadata + + +def test_review_filter_policy_is_explicit_and_can_be_cleared() -> None: + page = _read("frontend/src/pages/strategy/StrategyCenterPage.tsx") + assert "REVIEW_FILTER_POLICY = 'preserve-within-session'" in page + assert "data-filter-policy={mode === 'review' ? REVIEW_FILTER_POLICY : 'page-scoped'}" in page + assert "onSubNavigate(key)" in page + assert "setReviewFilters(DEFAULT_REVIEW_FILTERS)" in page + assert "清除筛选" in page + + +def test_review_selection_tracks_the_filtered_result_set() -> None: + page = _read("frontend/src/pages/strategy/StrategyCenterPage.tsx") + assert "filteredReviewRows.some((item: any) => item.key === current)" in page + assert "filteredReviewRows[0]?.key" in page + assert "rows.find((row: any) => row.key === selectedKey) || rows[0]" in _read( + "frontend/src/components/strategy/StrategyDesign.tsx" + ) + + +def test_strategy_source_metadata_has_bounded_narrow_screen_styles() -> None: + styles = _read("frontend/src/styles.css") + assert ".strategy-header-metadata .strategy-meta-run strong" in styles + assert ".strategy-header-metadata .strategy-meta-stale .ant-tag" in styles + assert ".strategy-header-metadata { flex-wrap: wrap; justify-content: flex-start; }" in styles + + +def test_p13b_does_not_add_frontend_business_fallbacks() -> None: + page = _read("frontend/src/pages/strategy/StrategyCenterPage.tsx") + service = _read("frontend/src/services/strategyApi.ts") + assert "Math.random" not in page + assert "mockFallback={false}" in page + assert "mockFallback: false" in service + assert "PHASE5_D_TEST_DATABASE_URL" not in page diff --git a/tests/test_p6_report_center_viewport_layout.py b/tests/test_p6_report_center_viewport_layout.py new file mode 100644 index 0000000..12685d8 --- /dev/null +++ b/tests/test_p6_report_center_viewport_layout.py @@ -0,0 +1,193 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def test_report_center_uses_one_shared_toolbar_and_status_row(): + page = _read("frontend/src/pages/report/ReportCenterPage.tsx") + + assert "import './report-center-layout.css';" in page + assert "报告中心 / 日报" in page + assert "报告中心 / 报告审核与发布" in page + assert page.count("' in page + + +def test_report_workspace_closes_the_viewport_and_scrolls_inside_panes(): + styles = _read("frontend/src/pages/report/report-center-layout.css") + + assert "height: 100%;" in styles + assert "flex-direction: column;" in styles + assert "flex: 1 1 0;" in styles + assert "grid-template-columns: minmax(260px, 286px) minmax(0, 1fr) minmax(300px, 320px);" in styles + assert ".report-workbench .report-list-scroll" in styles + assert ".report-workbench .report-preview-body" in styles + assert ".report-workbench .report-review-body" in styles + assert ".report-workbench .report-side-scroll" in styles + assert styles.count("overflow-y: auto;") >= 4 + assert "--report-ai-safe-area: 72px;" in styles + + +def test_report_toolbar_is_opaque_and_does_not_use_extreme_z_index(): + styles = _read("frontend/src/pages/report/report-center-layout.css") + toolbar = styles.split(".report-workbench .report-page-toolbar {", 1)[1].split("}", 1)[0] + + assert "position: sticky;" in toolbar + assert "isolation: isolate;" in _read("frontend/src/pages/report/report-center-layout.css") + assert "background: #fff;" in toolbar + assert "z-index: 20;" in toolbar + assert "999999" not in styles + + +def test_report_list_is_compact_and_preserves_full_text_via_tooltips(): + page = _read("frontend/src/pages/report/ReportCenterPage.tsx") + styles = _read("frontend/src/pages/report/report-center-layout.css") + + assert "height: 74px;" in styles + assert "grid-template-columns: minmax(0, 1fr) 68px;" in styles + assert ".report-workbench .report-list-item-copy" in styles + assert "flex-direction: column;" in styles + assert "max-width: 68px;" in styles + assert "-webkit-line-clamp: 2;" in styles + assert "text-overflow: ellipsis;" in styles + assert "" in page + assert "" in page + assert "shortTime(item.generated_at || item.updated_at || item.created_at)" in page + + +def test_report_successor_layout_uses_shared_dense_preview_contracts(): + styles = _read("frontend/src/pages/report/report-center-layout.css") + + assert "--report-toolbar-height: 54px;" in styles + assert "--report-kpi-height: 70px;" in styles + assert "height: 66px;" in styles + assert "grid-template-columns: minmax(0, 1.22fr) minmax(0, 1fr);" in styles + assert "width: 40% !important;" in styles + assert "height: 184px !important;" in styles + assert "height: 110px;" in styles + assert "height: 92px !important;" in styles + assert "grid-template-rows: repeat(2, minmax(0, 1fr));" in styles + + +def test_report_status_metrics_center_the_note_beside_the_value(): + styles = _read("frontend/src/pages/report/report-center-layout.css") + + metric_main = styles.split(".report-workbench .report-status-kpi-row .metric-main {", 1)[1].split("}", 1)[0] + title_row = styles.split(".report-workbench .report-status-kpi-row .metric-title-row {", 1)[1].split("}", 1)[0] + metric_value = styles.split(".report-workbench .report-status-kpi-row .metric-value {", 1)[1].split("}", 1)[0] + metric_note = styles.split(".report-workbench .report-status-kpi-row .metric-note {", 1)[1].split("}", 1)[0] + + assert "min-height: 0;" in metric_main + assert "grid-template-columns: minmax(0, 1fr) auto auto minmax(0, 1fr);" in metric_main + assert "grid-template-rows: auto minmax(0, 1fr);" in metric_main + assert "grid-column: 1 / -1;" in title_row + assert "grid-column: 2;" in metric_value + assert "grid-column: 3;" in metric_note + assert "margin-top: 0;" in metric_note + assert "white-space: nowrap;" in metric_note + + +def test_report_right_rail_bottom_edges_close_with_the_center_preview(): + styles = _read("frontend/src/pages/report/report-center-layout.css") + + daily_scroll = styles.split(".report-workbench .report-main-grid .report-side-scroll {", 1)[1].split("}", 1)[0] + daily_timeline = styles.split(".report-workbench .report-main-grid .report-timeline-card {", 1)[1].split("}", 1)[0] + review_scroll = styles.split(".report-workbench .report-review-side .report-side-scroll {", 1)[1].split("}", 1)[0] + review_grid = styles.split(".report-workbench .report-review-info-grid {", 1)[1].split("}", 1)[0] + review_card = styles.rsplit(".report-workbench .report-small-info {", 1)[1].split("}", 1)[0] + + assert "display: flex;" in daily_scroll + assert "padding-bottom: 0;" in daily_scroll + assert "width: 100%;" in daily_timeline + assert "height: 100%;" in daily_timeline + + assert "display: grid;" in review_scroll + assert "grid-template-rows: auto minmax(0, 1fr);" in review_scroll + assert "padding-bottom: 0;" in review_scroll + assert "height: 100%;" in review_grid + assert "grid-template-rows: repeat(2, minmax(0, 1fr));" in review_grid + assert "width: 100%;" in review_card + assert "height: 100%;" in review_card + + +def test_daily_and_review_pages_fill_the_viewport_without_clipping_publish_records(): + page = _read("frontend/src/pages/report/ReportCenterPage.tsx") + styles = _read("frontend/src/pages/report/report-center-layout.css") + + assert "report-workbench--${activeSubKey}" in page + for page_class in ( + ".report-workbench--report-daily", + ".report-workbench--report-review", + ".report-workbench--report-publish", + ): + assert f".content-shell:has({page_class})" in styles + + viewport_rule = styles.split( + ".content-shell:has(.report-workbench--report-publish) {", 1 + )[1].split("}", 1)[0] + assert "padding-right: 0 !important;" in viewport_rule + assert "padding-bottom: 0 !important;" in viewport_rule + assert "overflow: hidden !important;" in viewport_rule + assert "scrollbar-gutter: auto;" in viewport_rule + + timeline_body = styles.split( + ".report-workbench .report-main-grid .report-timeline-card .ant-card-body {", 1 + )[1].split("}", 1)[0] + timeline = styles.split( + ".report-workbench .report-main-grid .report-timeline-card .ant-timeline {", 1 + )[1].split("}", 1)[0] + timeline_copy = styles.split( + ".report-workbench .report-main-grid .report-timeline-card .ant-timeline-item-content span {", 1 + )[1].split("}", 1)[0] + assert "height: calc(100% - 37px);" in timeline_body + assert "flex: 1 1 auto;" in timeline_body + assert "height: 100%;" in timeline + assert "flex-direction: column;" in timeline + assert "white-space: normal;" in timeline_copy + assert "text-overflow: clip;" in timeline_copy + + +def test_report_business_handlers_permissions_and_state_guards_are_explicit(): + page = _read("frontend/src/pages/report/ReportCenterPage.tsx") + + for handler in ( + "api.generateReport()", + "api.regenerateReport(activeReport.report_id)", + "api.approveReport(activeReport.report_id, payload)", + "api.rejectReport(activeReport.report_id, payload)", + "api.publishReport(activeReport.report_id, payload)", + "api.reportDownload(activeReport.report_id)", + ): + assert handler in page + assert "canPerformAction" in page + for permission in ( + "report:download", + "report.generate", + "report:review", + "report.approve", + "report.reject", + "report.publish", + ): + assert permission in page + for label in ( + "下载报告", + "下载报告(Excel)", + "查看详情", + "进入审核", + "重新生成", + "复制报告链接", + "归档报告", + "删除报告", + "通过", + "驳回", + "发布报告", + "仅已驳回报告允许重新生成", + "仅已通过报告允许发布", + ): + assert label in page diff --git a/tests/test_phase4_precheck_runtime.py b/tests/test_phase4_precheck_runtime.py new file mode 100644 index 0000000..074d69a --- /dev/null +++ b/tests/test_phase4_precheck_runtime.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "phase4_precheck_runtime.py" +SPEC = importlib.util.spec_from_file_location("phase4_precheck_runtime", MODULE_PATH) +assert SPEC and SPEC.loader +runtime = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(runtime) + + +def test_masked_url_never_exposes_credentials(): + masked = runtime.masked_url("redis://user:very-secret@127.0.0.1:6379/3") + assert "very-secret" not in masked + assert "user" not in masked + assert masked == "redis://***@127.0.0.1:6379/3" + + +def test_health_worker_is_isolated_to_safe_queue(monkeypatch): + monkeypatch.setenv("PHASE4_WORKER_ROLE", "health") + monkeypatch.setenv("PHASE4_CELERY_QUEUE", "phase4_health") + command = runtime._celery_worker_command() + joined = " ".join(command) + assert "--pool=solo" in command + assert "--concurrency=1" in command + assert "--queues=phase4_health" in command + assert "forecast" not in joined + assert "report" not in joined + assert "data_sync" not in joined + assert "embedding" not in joined + assert runtime.WORKER_START_TIMEOUT_SECONDS >= 180 + + +def test_forecast_worker_uses_dedicated_queue_and_runtime_paths(monkeypatch): + monkeypatch.setenv("PHASE4_WORKER_ROLE", "forecast") + monkeypatch.setenv("FORECAST_CELERY_QUEUE", "forecast_final_rc") + + command = runtime._celery_worker_command() + paths = runtime.runtime_paths() + + assert "--queues=forecast_final_rc" in command + assert "--hostname=forecast-worker@%h" in command + assert paths["pid_file"].name == "celery_forecast_worker.json" + assert paths["worker_log"].name == "celery_forecast_worker.log" + assert all(path.is_relative_to(ROOT) for path in paths.values()) + + +def test_knowledge_worker_consumes_only_knowledge_queues(monkeypatch): + monkeypatch.setenv("PHASE4_WORKER_ROLE", "knowledge") + monkeypatch.setenv("KNOWLEDGE_CELERY_QUEUES", "rag,embedding,report") + + command = runtime._celery_worker_command() + paths = runtime.runtime_paths() + + assert "--queues=rag,embedding,report" in command + assert "--hostname=knowledge-worker@%h" in command + assert paths["pid_file"].name == "celery_knowledge_worker.json" + assert paths["worker_log"].name == "celery_knowledge_worker.log" + assert all(path.is_relative_to(ROOT) for path in paths.values()) + + +def test_default_knowledge_worker_covers_landed_non_forecast_business_queues(monkeypatch): + monkeypatch.setenv("PHASE4_WORKER_ROLE", "knowledge") + monkeypatch.delenv("KNOWLEDGE_CELERY_QUEUES", raising=False) + + command = runtime._celery_worker_command() + + queue_argument = next(item for item in command if item.startswith("--queues=")) + queues = set(queue_argument.removeprefix("--queues=").split(",")) + assert {"rag", "embedding", "report", "data_sync", "report_daily", "default"} <= queues + + +def test_knowledge_reader_profile_maps_only_read_only_credentials(monkeypatch, tmp_path): + profile = tmp_path / "reader.env" + profile.write_text( + "QDRANT_API_KEY=reader-only\n" + "QDRANT_READ_ONLY_API_KEY=reader-only\n" + "QDRANT_CA_CERT=E:/runtime/ca.pem\n" + "QDRANT_URL=https://127.0.0.1:6333\n", + encoding="utf-8", + ) + monkeypatch.setenv("RAG_READER_CONFIG", str(profile)) + + runtime._load_knowledge_reader_env() + + assert runtime.os.environ["RAG_QDRANT_API_KEY"] == "reader-only" + assert runtime.os.environ["RAG_QDRANT_URL"] == "https://127.0.0.1:6333" + + +def test_runtime_paths_stay_inside_project(monkeypatch): + monkeypatch.setenv("PHASE4_WORKER_ROLE", "health") + monkeypatch.delenv("PHASE4_RUNTIME_DIR", raising=False) + monkeypatch.delenv("PHASE4_RUNTIME_LOG_DIR", raising=False) + paths = runtime.runtime_paths() + for path in paths.values(): + assert path.is_relative_to(ROOT) + assert path.drive.upper() == "E:" + + +def test_runtime_helper_adds_project_root_to_import_path(): + assert str(ROOT) in runtime.sys.path + + +def test_pid_active_recognizes_current_windows_process(): + assert runtime._pid_active(os.getpid()) is True + + +def test_non_local_redis_target_is_fail_closed(): + assert runtime._local_redis_target("redis://127.0.0.1:6379/0") is True + assert runtime._local_redis_target("redis://localhost:6379/0") is True + assert runtime._local_redis_target("redis://redis.example.com:6379/0") is False + + +def test_docker_command_is_scoped_to_project_compose(monkeypatch): + monkeypatch.setattr(runtime.shutil, "which", lambda _: "docker") + monkeypatch.setenv("PHASE4_DOCKER_CONTEXT", "desktop-linux") + command = runtime._docker_base_command() + assert command[:3] == ["docker", "--context", "desktop-linux"] + assert command[3:] == ["compose", "-f", str(ROOT / "docker-compose.yml")] + + +def test_compose_redis_port_is_loopback_only(): + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + assert '127.0.0.1:${REDIS_PORT:-6379}:6379' in compose + + +def test_explicit_runtime_config_populates_compose_environment( + monkeypatch, tmp_path +): + monkeypatch.delenv("POSTGRES_PASSWORD", raising=False) + monkeypatch.delenv("DB_PASSWORD", raising=False) + config = tmp_path / "runtime.env" + config.write_text("DB_PASSWORD=not-logged\n", encoding="utf-8") + + runtime.configure_runtime([config]) + + docker_env = runtime._docker_env() + assert docker_env["POSTGRES_PASSWORD"] == "not-logged" + assert docker_env["APP_DB_PASSWORD"] == "not-logged" + assert docker_env["SECURITY_DB_PASSWORD"] == "not-logged" + + +def test_missing_explicit_runtime_config_fails_closed(tmp_path): + missing = tmp_path / "missing.env" + + try: + runtime.configure_runtime([missing]) + except RuntimeError as exc: + assert "Runtime config does not exist" in str(exc) + else: + raise AssertionError("missing explicit runtime config must fail") diff --git a/tests/test_report_governance.py b/tests/test_report_governance.py new file mode 100644 index 0000000..cef1a9d --- /dev/null +++ b/tests/test_report_governance.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import json +import os +import uuid +from contextlib import contextmanager +from unittest.mock import Mock + +import pytest +from sqlalchemy import create_engine, text +from starlette.requests import Request + +from backend.app.api.v1.endpoints import report as report_endpoint +from backend.app.core import security +from backend.app.core.security import CurrentUser +from backend.app.services import task_business_handlers +from backend.app.services.report_generation_service import ( + _safe_report_id, + _versioned_report_id, +) +from backend.app.services.report_governance_service import ( + ReportGovernanceError, + list_report_review_events, + transition_report, + validate_report_transition, +) + + +def _user(username: str, *permissions: str, role: str = "reviewer") -> CurrentUser: + return CurrentUser( + user_id=f"user-{username}", + username=username, + role=role, + permissions=list(permissions), + auth_mode="test", + ) + + +def _request() -> Request: + return Request({"type": "http", "method": "POST", "path": "/", "headers": []}) + + +@pytest.mark.parametrize("action", ["approve", "reject"]) +def test_review_transition_requires_explicit_comment(action: str) -> None: + with pytest.raises(ReportGovernanceError) as caught: + validate_report_transition( + current_status="ready", + action=action, + actor=_user("reviewer-a", "report:review"), + comment="", + ) + assert caught.value.code == "report_review_comment_required" + assert caught.value.status_code == 400 + + +def test_publish_requires_approved_non_stale_report_and_publish_permission() -> None: + with pytest.raises(ReportGovernanceError) as wrong_state: + validate_report_transition( + current_status="ready", + action="publish", + actor=_user("publisher", "report:publish"), + comment="", + ) + assert wrong_state.value.code == "report_transition_conflict" + + with pytest.raises(ReportGovernanceError) as stale: + validate_report_transition( + current_status="approved", + action="publish", + actor=_user("publisher", "report:publish"), + comment="", + metadata={"is_stale": True}, + ) + assert stale.value.code == "stale_report_cannot_publish" + + with pytest.raises(ReportGovernanceError) as denied: + validate_report_transition( + current_status="approved", + action="publish", + actor=_user("reviewer-a", "report:review"), + comment="", + ) + assert denied.value.code == "report_permission_denied" + + assert validate_report_transition( + current_status="approved", + action="publish", + actor=_user("publisher", "report:publish"), + comment="", + ) == ("published", False) + + +def test_actor_spoof_and_self_approval_are_rejected() -> None: + actor = _user("reviewer-a", "report:review") + with pytest.raises(ReportGovernanceError) as spoofed: + validate_report_transition( + current_status="ready", + action="approve", + actor=actor, + comment="核验通过", + requested_reviewer="admin", + ) + assert spoofed.value.code == "report_actor_mismatch" + + with pytest.raises(ReportGovernanceError) as self_approval: + validate_report_transition( + current_status="ready", + action="approve", + actor=actor, + comment="核验通过", + metadata={"created_by": "reviewer-a"}, + ) + assert self_approval.value.code == "report_self_approval_forbidden" + + +def test_dynamic_role_configuration_cannot_escalate_static_ceiling(monkeypatch) -> None: + monkeypatch.setattr( + security, + "list_role_permissions", + lambda: [{"role_id": "analyst", "permissions": ["report:read", "report:review", "report:publish"]}], + ) + resolved = security.permissions_for_role("analyst") + assert "report:read" in resolved + assert "report:review" not in resolved + assert "report:publish" not in resolved + + monkeypatch.setattr( + security, + "list_role_permissions", + lambda: [{"role_id": "admin", "permissions": ["report:publish"]}], + ) + assert security.permissions_for_role("admin") == [] + + +def test_regenerate_enqueues_exact_rejected_report_lineage(monkeypatch) -> None: + captured: dict = {} + monkeypatch.setattr( + report_endpoint, + "report_status", + lambda report_id: { + "report_id": report_id, + "run_id": "run-accepted-24h", + "report_type": "weekly", + "status": "rejected", + "metadata": {"region": "华东"}, + }, + ) + + def enqueue(kind: str, payload: dict): + captured.update({"kind": kind, "payload": payload}) + return {"task_id": "task-version-2", "status": "pending"} + + monkeypatch.setattr(report_endpoint, "enqueue_task", enqueue) + monkeypatch.setattr(report_endpoint, "write_audit_log", lambda **_: True) + result = report_endpoint.report_regenerate( + "report-v1", + _request(), + _user("analyst-a", "report:generate", role="analyst"), + ) + + assert result["accepted"] is True + assert captured["kind"] == "report_generate" + assert captured["payload"] == { + "run_id": "run-accepted-24h", + "report_type": "weekly", + "region": "华东", + "source_report_id": "report-v1", + "regenerate": True, + "requested_by": "analyst-a", + } + + +def test_report_task_handler_forwards_regeneration_contract(monkeypatch) -> None: + captured: dict = {} + monkeypatch.setattr(task_business_handlers, "postgres_engine", lambda: Mock()) + + def generate(_engine, **kwargs): + captured.update(kwargs) + return {"report_id": "report-v2", "run_id": "run-accepted-24h", "idempotent": False} + + monkeypatch.setattr(task_business_handlers, "generate_operational_report", generate) + task_business_handlers.run_report_daily( + { + "run_id": "run-accepted-24h", + "report_type": "weekly", + "source_report_id": "report-v1", + "requested_by": "analyst-a", + } + ) + assert captured["source_report_id"] == "report-v1" + assert captured["requested_by"] == "analyst-a" + + +def test_versioned_report_ids_fit_database_contract() -> None: + root = _safe_report_id("run-" + "x" * 200, "operation_decision") + versioned = _versioned_report_id(root, 12) + assert len(root) <= 64 + assert len(versioned) <= 64 + assert versioned.endswith("_v12") + + +def test_local_postgres_transition_is_atomic_auditable_and_idempotent() -> None: + url = os.getenv("REPORT_GOVERNANCE_ACCEPTANCE_URL", "").strip() + if not url: + pytest.skip("REPORT_GOVERNANCE_ACCEPTANCE_URL is not configured") + engine = create_engine(url, future=True) + report_id = f"audit_rpt_{uuid.uuid4().hex[:20]}" + invalid_id = f"audit_invalid_{uuid.uuid4().hex[:16]}" + reviewer = _user("audit-reviewer", "report:review") + publisher = _user("audit-publisher", "report:publish", role="admin") + metadata = json.dumps({"created_by": "audit-generator", "is_stale": False}) + connection = engine.connect() + outer_transaction = connection.begin() + + class RollbackOnlyEngine: + @contextmanager + def begin(self): + savepoint = connection.begin_nested() + try: + yield connection + except Exception: + savepoint.rollback() + raise + else: + savepoint.commit() + + @contextmanager + def connect(self): + yield connection + + controlled_engine = RollbackOnlyEngine() + try: + connection.execute( + text( + """ + INSERT INTO report_runs ( + report_id, run_id, title, status, report_type, + metadata_json, content_json, generated_at + ) VALUES ( + :report_id, 'audit-run-governance', '报告治理受控验收记录', + 'ready', 'daily', CAST(:metadata AS jsonb), CAST('{}' AS jsonb), + CURRENT_TIMESTAMP + ) + """ + ), + [{"report_id": value, "metadata": metadata} for value in (report_id, invalid_id)], + ) + approved = transition_report( + report_id, + action="approve", + actor=reviewer, + comment="受控验收:预测事实与报告摘要已核对。", + engine=controlled_engine, + ) + published = transition_report( + report_id, + action="publish", + actor=publisher, + comment="受控验收发布。", + engine=controlled_engine, + ) + repeated = transition_report( + report_id, + action="publish", + actor=publisher, + comment="重复请求不应新增治理事件。", + engine=controlled_engine, + ) + with pytest.raises(ReportGovernanceError) as invalid: + transition_report( + invalid_id, + action="publish", + actor=publisher, + comment="非法直接发布。", + engine=controlled_engine, + ) + events = list_report_review_events(report_id, engine=controlled_engine) + states = dict( + connection.execute( + text("SELECT report_id, status FROM report_runs WHERE report_id IN (:valid, :invalid)"), + {"valid": report_id, "invalid": invalid_id}, + ).all() + ) + invalid_events = connection.execute( + text("SELECT COUNT(*) FROM report_reviews WHERE report_id=:report_id"), + {"report_id": invalid_id}, + ).scalar_one() + assert approved["transition_applied"] is True + assert published["transition_applied"] is True + assert repeated["idempotent"] is True + assert invalid.value.code == "report_transition_conflict" + assert states == {report_id: "published", invalid_id: "ready"} + assert invalid_events == 0 + assert len(events) == 2 + assert {event["reviewer"] for event in events} == {"audit-reviewer", "audit-publisher"} + finally: + outer_transaction.rollback() + connection.close() + with engine.connect() as verification: + residual = verification.execute( + text("SELECT COUNT(*) FROM report_runs WHERE report_id IN (:valid, :invalid)"), + {"valid": report_id, "invalid": invalid_id}, + ).scalar_one() + engine.dispose() + assert residual == 0 diff --git a/tests/test_settings_center_business_api.py b/tests/test_settings_center_business_api.py new file mode 100644 index 0000000..853e801 --- /dev/null +++ b/tests/test_settings_center_business_api.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.exc import SQLAlchemyError + +from backend.app.auth.jwt import decode_access_token +from backend.app.auth.password import hash_password +from backend.app.core.config import reset_settings_cache +from backend.app.db.session import reset_db_cache +from backend.app.main import app +from backend.app.repositories import audit_repository, settings_repository, user_repository +from backend.app.repositories.audit_repository import clear_memory_audit_logs +from backend.app.repositories.settings_repository import clear_memory_settings +from backend.app.repositories.user_repository import clear_memory_users, create_user +from backend.app.services import settings_center_service + + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def _settings_center_env(monkeypatch): + monkeypatch.setenv("AUTH_REQUIRED", "0") + monkeypatch.setenv("JWT_SECRET_KEY", "settings_center_test_jwt_secret") + monkeypatch.setenv("DATABASE_URL", "") + monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0") + reset_settings_cache() + reset_db_cache() + monkeypatch.setattr(settings_repository, "postgres_engine", lambda: None) + monkeypatch.setattr(user_repository, "postgres_engine", lambda: None) + monkeypatch.setattr(audit_repository, "postgres_engine", lambda: None) + clear_memory_settings() + clear_memory_users() + clear_memory_audit_logs() + create_user( + username="admin", + password_hash=hash_password("AdminPass123!"), + display_name="admin", + role="admin", + is_active=True, + ) + yield + clear_memory_settings() + clear_memory_users() + clear_memory_audit_logs() + reset_settings_cache() + reset_db_cache() + + +def test_settings_status_and_runtime_config_endpoints(): + overview = client.get("/api/settings/status/overview") + assert overview.status_code == 200 + assert "healthy_services" in overview.json() + + summary = client.get("/api/settings/status/summary") + assert summary.status_code == 200 + assert isinstance(summary.json()["items"], list) + + details = client.get("/api/settings/status/health-details") + assert details.status_code == 200 + assert isinstance(details.json()["items"], list) + + runtime = client.get("/api/settings/runtime-config") + assert runtime.status_code == 200 + assert "data_refresh_interval_minutes" in runtime.json()["values"] + + update = client.put("/api/settings/runtime-config", json={"values": {"data_refresh_interval_minutes": 9}}) + assert update.status_code == 200 + assert update.json()["values"]["data_refresh_interval_minutes"] == 9 + + records = client.get("/api/settings/status/check-records?limit=5") + assert records.status_code == 200 + assert isinstance(records.json()["items"], list) + assert records.json()["items"][0]["status"] == "unavailable" + + +def test_settings_get_endpoints_merge_defaults_without_writing_storage(): + assert settings_repository._RUNTIME_MEMORY == {} + assert settings_repository._API_CONFIG_MEMORY == {} + + assert client.get("/api/settings/runtime-config").status_code == 200 + assert client.get("/api/settings/security-policy").status_code == 200 + assert client.get("/api/settings/roles/permissions").status_code == 200 + interfaces = client.get("/api/settings/interfaces/configs") + + assert interfaces.status_code == 200 + keys = {item["interface_key"] for item in interfaces.json()["items"]} + assert {"llm_kimi", "llm_mimo", "llm_deepseek"} <= keys + assert settings_repository._RUNTIME_MEMORY == {} + assert settings_repository._API_CONFIG_MEMORY == {} + + +def test_health_snapshot_store_uses_application_runtime_identity(monkeypatch): + application_engine = object() + monkeypatch.setattr(settings_repository, "_application_postgres_engine", lambda: application_engine) + monkeypatch.setattr( + settings_repository, + "postgres_engine", + lambda: (_ for _ in ()).throw(AssertionError("security identity must not serve health snapshots")), + ) + + assert settings_repository.health_postgres_engine() is application_engine + + +def test_health_snapshot_queries_fail_closed_without_database_or_permission(monkeypatch): + clear_memory_settings() + monkeypatch.setattr(settings_repository, "health_postgres_engine", lambda: None) + missing_dsn = settings_repository.health_check_records(limit=5) + assert missing_dsn[0]["status"] == "unavailable" + assert missing_dsn[0]["extra_json"]["reason"] == "database_not_configured_or_unreachable" + + class PermissionDeniedEngine: + def connect(self): + raise SQLAlchemyError("permission denied") + + monkeypatch.setattr(settings_repository, "health_postgres_engine", lambda: PermissionDeniedEngine()) + denied = settings_repository.latest_health_snapshots(limit=5) + assert denied[0]["status"] == "unavailable" + assert denied[0]["extra_json"]["reason"] == "query_unavailable" + + +def test_runtime_health_get_path_does_not_persist_snapshots(monkeypatch): + clear_memory_settings() + monkeypatch.setattr( + settings_center_service, + "database_runtime_status", + lambda: {"active": "postgresql", "available": True, "message": "available"}, + ) + monkeypatch.setattr( + settings_center_service, + "task_runtime_health", + lambda: {"redis": {"ok": True, "status": "ok"}, "celery": {"ok": True, "worker_count": 1}}, + ) + monkeypatch.setattr(settings_center_service, "rag_health", lambda: {"ok": True, "status": "normal"}) + monkeypatch.setattr(settings_center_service, "get_local_llm_status", lambda: {"available": False}) + + rows = settings_center_service.collect_runtime_health_rows() + + assert rows + assert settings_repository._HEALTH_MEMORY == [] + + +def test_settings_user_role_security_and_audit_endpoints(): + created = client.post( + "/api/settings/users", + json={ + "username": "zhangsan", + "display_name": "张三", + "email": "zhangsan@example.com", + "password": "Password123!", + "role": "analyst", + }, + ) + assert created.status_code == 201 + user_id = created.json()["user"]["user_id"] + + users = client.get("/api/settings/users?keyword=zhangsan") + assert users.status_code == 200 + assert users.json()["total"] == 1 + + assign = client.post(f"/api/settings/users/{user_id}/assign-role", json={"role": "developer"}) + assert assign.status_code == 200 + assert assign.json()["user"]["role"] == "developer" + + reset = client.post(f"/api/settings/users/{user_id}/reset-password", json={"new_password": "NewPassword123!"}) + assert reset.status_code == 200 + assert "NewPassword123!" not in str(client.get("/api/settings/audit-logs").json()) + + roles = client.get("/api/settings/roles/permissions") + assert roles.status_code == 200 + assert any(row["role_id"] == "admin" for row in roles.json()["roles"]) + assert any(row["role_id"] == "admin" and row["admin"] for row in roles.json()["matrix"]) + + role_update = client.put( + "/api/settings/roles/permissions", + json={"roles": [{"role_id": "viewer", "role_name": "查看者", "permissions": ["dashboard:read", "audit:read"]}]}, + ) + assert role_update.status_code == 200 + viewer = next(row for row in role_update.json()["roles"] if row["role_id"] == "viewer") + assert viewer["permissions"] == ["audit:read", "dashboard:read"] + + security = client.put("/api/settings/security-policy", json={"values": {"password_min_length": 14}}) + assert security.status_code == 200 + assert security.json()["values"]["password_min_length"] == 14 + assert client.put("/api/settings/security-policy", json={"values": {"password_min_length": 7}}).status_code == 400 + assert client.put("/api/settings/security-policy", json={"values": {"two_factor_enabled": True}}).status_code == 400 + + overview = client.get("/api/settings/users/overview") + assert overview.status_code == 200 + assert overview.json()["user_total"] >= 2 + + +def test_settings_interface_configs_test_and_logs(): + configs = client.get("/api/settings/interfaces/configs") + assert configs.status_code == 200 + assert configs.json()["total"] >= 1 + assert "AdminPass123!" not in str(configs.json()) + + web_test = client.post("/api/settings/interfaces/web/test") + assert web_test.status_code == 200 + assert web_test.json()["ok"] is True + + logs = client.get("/api/settings/interfaces/test-logs") + assert logs.status_code == 200 + assert logs.json()["total"] >= 1 + assert logs.json()["items"][0]["interface_key"] == "web" + + overview = client.get("/api/settings/interfaces/overview") + assert overview.status_code == 200 + assert overview.json()["interface_total"] >= 1 + + all_tests = client.post("/api/settings/interfaces/test-all") + assert all_tests.status_code == 200 + assert "items" in all_tests.json() + + +def test_remote_provider_interface_probe_uses_real_adapter_contract_without_exposing_content(monkeypatch): + class FakeProvider: + name = "kimi" + display_name = "Kimi K2.6" + default_model = "kimi-k2.6" + available = True + + def complete(self, _messages, **_options): + return type("Result", (), {"content": "sensitive answer", "model": self.default_model, "finish_reason": "stop"})() + + monkeypatch.setitem(settings_center_service.PROVIDER_FACTORIES, "llm_kimi", FakeProvider) + response = client.post("/api/settings/interfaces/llm_kimi/test") + + assert response.status_code == 200 + payload = response.json() + assert payload["ok"] is True + assert payload["extra"]["provider"] == "kimi" + assert "sensitive answer" not in str(payload) + + refreshed = client.get("/api/settings/interfaces?page_size=100") + kimi = next(item for item in refreshed.json()["items"] if item["interface_key"] == "llm_kimi") + assert kimi["status"] == "normal" + assert kimi["success_rate"] == 100 + assert kimi["persisted"] is True + + +def test_login_lock_threshold_and_session_timeout_policy_are_enforced(): + create_user( + username="locked1", password_hash=hash_password("Password123!"), + display_name="locked1", role="viewer", is_active=True, + ) + settings_repository.upsert_runtime_config( + "login_failed_lock_count", 3, value_type="integer", category="security_policy" + ) + settings_repository.upsert_runtime_config( + "session_timeout_minutes", 7, value_type="integer", category="security_policy" + ) + + assert client.post("/api/auth/login", json={"username": "locked1", "password": "wrong"}).status_code == 401 + assert client.post("/api/auth/login", json={"username": "locked1", "password": "wrong"}).status_code == 401 + assert client.post("/api/auth/login", json={"username": "locked1", "password": "wrong"}).status_code == 423 + assert client.post("/api/auth/login", json={"username": "locked1", "password": "Password123!"}).status_code == 423 + + login = client.post("/api/auth/login", json={"username": "admin", "password": "AdminPass123!"}) + assert login.status_code == 200 + token = decode_access_token(login.json()["access_token"]) + assert token["exp"] - token["iat"] == 7 * 60 + + +def test_login_uses_persisted_role_configuration(): + settings_repository.upsert_role_permissions( + "viewer", ["dashboard:read", "assistant:use"], role_name="查看者" + ) + create_user( + username="configured1", password_hash=hash_password("Password123!"), + display_name="configured1", role="viewer", is_active=True, + ) + + response = client.post("/api/auth/login", json={"username": "configured1", "password": "Password123!"}) + + assert response.status_code == 200 + assert response.json()["user"]["permissions"] == ["dashboard:read"] diff --git a/tests/test_t001_model_fact_source.py b/tests/test_t001_model_fact_source.py new file mode 100644 index 0000000..4ae921c --- /dev/null +++ b/tests/test_t001_model_fact_source.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from fastapi.testclient import TestClient +from sqlalchemy import event, text +from sqlalchemy.exc import IntegrityError + +from backend.app.main import app +from backend.app.repositories import model_repository +from backend.app.services.core_data_sync import sync_model_facts +from backend.app.services.model_fact_service import ModelFactError, ModelFactService +from model_ops import active_model_loader + + +ROOT = Path(__file__).resolve().parents[1] +MIGRATION_PATH = ROOT / "migrations" / "versions" / "0013_t001_model_fact_source.py" + + +def _migration_module(): + spec = importlib.util.spec_from_file_location("t001_migration", MIGRATION_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _create_legacy_schema(engine: sa.Engine) -> None: + with engine.begin() as conn: + conn.execute( + text( + """ + CREATE TABLE model_registry ( + model_version VARCHAR(64) PRIMARY KEY, + model_role VARCHAR(32) NOT NULL, + train_start_date DATE, + train_end_date DATE, + feature_version VARCHAR(64), + artifact_path VARCHAR(500) NOT NULL, + test_mae NUMERIC(12,6), + test_rmse NUMERIC(12,6), + peak_rmse NUMERIC(12,6), + spike_rmse NUMERIC(12,6), + rolling_rmse NUMERIC(12,6), + is_active SMALLINT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'candidate', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + activated_at TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE model_versions ( + id INTEGER PRIMARY KEY, + model_version VARCHAR(128) NOT NULL UNIQUE, + status VARCHAR(32), + is_active BOOLEAN, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + "INSERT INTO model_versions (id, model_version, status, is_active) " + "VALUES (1, 'legacy-seed-active', 'Active', 1)" + ) + ) + conn.execute( + text( + """ + CREATE TABLE prediction_tracking ( + id INTEGER PRIMARY KEY, model_version VARCHAR(64), actual_price REAL, + abs_error REAL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE model_metrics ( + id INTEGER PRIMARY KEY, model_version VARCHAR(128), metric_date DATE, + mae REAL, rmse REAL, r2 REAL, mape REAL, peak_error REAL, + sample_count INTEGER, metrics_json TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE task_runs ( + task_id VARCHAR(64) PRIMARY KEY, task_name VARCHAR(128), task_kind VARCHAR(64), + status VARCHAR(32), payload_json TEXT, started_at TIMESTAMP, ended_at TIMESTAMP, + duration_seconds REAL, created_at TIMESTAMP, updated_at TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE model_governance_events ( + id INTEGER PRIMARY KEY, event_id VARCHAR(64), action VARCHAR(64), + target_version VARCHAR(128), source_version VARCHAR(128), operator VARCHAR(128), + reason TEXT, status VARCHAR(32), metadata_json TEXT, created_at TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE model_prediction_comparison_points ( + id INTEGER PRIMARY KEY, model_type VARCHAR(64), region VARCHAR(64), + forecast_time TIMESTAMP, active_version VARCHAR(128), candidate_version VARCHAR(128), + actual_value REAL, active_prediction REAL, candidate_prediction REAL, + diff_value REAL, data_origin VARCHAR(32) + ) + """ + ) + ) + + +def _apply_migration(engine: sa.Engine, monkeypatch: pytest.MonkeyPatch): + module = _migration_module() + with engine.begin() as conn: + monkeypatch.setattr(module, "op", Operations(MigrationContext.configure(conn))) + module.upgrade() + return module + + +@pytest.fixture +def isolated_engine(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> sa.Engine: + engine = sa.create_engine(f"sqlite+pysqlite:///{tmp_path / 't001.sqlite'}", future=True) + _create_legacy_schema(engine) + _apply_migration(engine, monkeypatch) + yield engine + engine.dispose() + + +def _candidate(version: str, *, domain: str = "price", target_name: str = "da_price") -> dict: + return { + "model_id": f"id-{version}", + "model_version": version, + "domain": domain, + "target_name": target_name, + "model_role": domain, + "artifact_id": f"artifact-{version}", + "artifact_path": f"model_artifacts/{version}", + "feature_version": "features-test", + "schema_hash": hashlib.sha256(f"schema:{version}".encode()).hexdigest(), + "source_type": "test", + } + + +def _snapshot(engine: sa.Engine) -> str: + with engine.connect() as conn: + registry = conn.execute( + text( + """ + SELECT model_id, model_version, domain, target_name, status, is_active, + created_at, validated_at, activated_at, deactivated_at, source_type + FROM model_registry ORDER BY model_version + """ + ) + ).all() + legacy = conn.execute( + text("SELECT id, model_version, status, is_active, updated_at FROM model_versions ORDER BY id") + ).all() + payload = {"registry": [tuple(row) for row in registry], "legacy": [tuple(row) for row in legacy]} + return hashlib.sha256(json.dumps(payload, default=str, sort_keys=True).encode()).hexdigest() + + +def test_migration_upgrade_downgrade_is_non_destructive(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + engine = sa.create_engine(f"sqlite+pysqlite:///{tmp_path / 'migration.sqlite'}", future=True) + _create_legacy_schema(engine) + with engine.connect() as conn: + before = { + "registry": conn.execute(text("SELECT COUNT(*) FROM model_registry")).scalar_one(), + "versions": conn.execute(text("SELECT COUNT(*) FROM model_versions")).scalar_one(), + } + module = _apply_migration(engine, monkeypatch) + inspector = sa.inspect(engine) + columns = {item["name"] for item in inspector.get_columns("model_registry")} + indexes = {item["name"] for item in inspector.get_indexes("model_registry")} + assert {"model_id", "domain", "target_name", "artifact_id", "schema_hash", "validated_at", "deactivated_at", "source_type"} <= columns + assert "uq_model_registry_active_domain_target" in indexes + assert "uq_model_registry_model_id" in indexes + with engine.begin() as conn: + monkeypatch.setattr(module, "op", Operations(MigrationContext.configure(conn))) + module.downgrade() + indexes_after = {item["name"] for item in sa.inspect(engine).get_indexes("model_registry")} + assert "uq_model_registry_active_domain_target" not in indexes_after + assert "model_id" in {item["name"] for item in sa.inspect(engine).get_columns("model_registry")} + with engine.connect() as conn: + after = { + "registry": conn.execute(text("SELECT COUNT(*) FROM model_registry")).scalar_one(), + "versions": conn.execute(text("SELECT COUNT(*) FROM model_versions")).scalar_one(), + } + assert after == before == {"registry": 0, "versions": 1} + engine.dispose() + + +def test_lifecycle_unique_active_rollback_and_domain_isolation(isolated_engine: sa.Engine): + service = ModelFactService(isolated_engine) + first = service.register_candidate(_candidate("model_20260620_063015")) + assert first["status"] == "candidate" and first["is_active"] is False + with pytest.raises(ModelFactError, match="validated"): + service.activate_model(first["model_version"], "price", "da_price") + + service.validate_candidate(first["model_version"], "price", "da_price") + assert service.activate_model(first["model_version"], "price", "da_price")["status"] == "active" + + second = service.register_candidate(_candidate("price-v2")) + service.mark_validating(second["model_version"], "price", "da_price") + service.validate_candidate(second["model_version"], "price", "da_price") + service.activate_model(second["model_version"], "price", "da_price") + assert service.get_model(first["model_version"], "price", "da_price")["status"] == "archived" + assert service.get_active_model("price", "da_price")["model_version"] == "price-v2" + + service.rollback_active_model(first["model_version"], "price", "da_price") + assert service.get_active_model("price", "da_price")["model_version"] == first["model_version"] + + load = service.register_candidate(_candidate("load-v1", domain="load", target_name="actual_load")) + service.validate_candidate(load["model_version"], "load", "actual_load") + service.activate_model(load["model_version"], "load", "actual_load") + assert service.get_active_model("load", "actual_load")["model_version"] == "load-v1" + assert all(item["domain"] == "price" for item in service.list_models("price", "da_price")) + assert all(item["domain"] == "load" for item in service.list_models("load", "actual_load")) + with pytest.raises(ModelFactError): + service.list_models("", "da_price") + + with pytest.raises(IntegrityError): + with isolated_engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO model_registry ( + model_id, model_version, domain, target_name, model_role, + artifact_path, status, is_active, source_type + ) VALUES ('duplicate-id', 'duplicate-active', 'price', 'da_price', 'price', + 'model_artifacts/duplicate', 'active', 1, 'test') + """ + ) + ) + + +def test_activation_failure_rolls_back_entire_transaction(isolated_engine: sa.Engine): + service = ModelFactService(isolated_engine) + for version in ("stable-v1", "candidate-v2"): + service.register_candidate(_candidate(version)) + service.validate_candidate(version, "price", "da_price") + service.activate_model("stable-v1", "price", "da_price") + + def fail_target_activation(_conn, _cursor, statement, _params, _context, _many): + normalized = " ".join(statement.lower().split()) + if normalized.startswith("update model_registry set status = 'active'"): + raise RuntimeError("injected activation failure") + + event.listen(isolated_engine, "before_cursor_execute", fail_target_activation) + try: + with pytest.raises(ModelFactError, match="事务已回滚"): + service.activate_model("candidate-v2", "price", "da_price") + finally: + event.remove(isolated_engine, "before_cursor_execute", fail_target_activation) + + assert service.get_active_model("price", "da_price")["model_version"] == "stable-v1" + assert service.get_model("candidate-v2", "price", "da_price")["status"] == "validated" + + +def test_governed_rollback_commits_state_and_event_atomically(isolated_engine: sa.Engine): + service = ModelFactService(isolated_engine) + for version in ("rollback-stable", "rollback-current"): + service.register_candidate(_candidate(version)) + service.validate_candidate(version, "price", "da_price") + service.activate_model("rollback-stable", "price", "da_price") + service.activate_model("rollback-current", "price", "da_price") + + result = service.rollback_active_model( + "rollback-stable", + "price", + "da_price", + governance_event={ + "event_id": "event-rollback-success", + "action": "model.rollback", + "operator": "admin", + "reason": "controlled rollback acceptance", + "metadata": {"scope": "isolated"}, + }, + ) + + assert result["model_version"] == "rollback-stable" + assert result["previous_active_version"] == "rollback-current" + with isolated_engine.connect() as conn: + event_row = conn.execute( + text( + "SELECT action, target_version, source_version, operator, reason " + "FROM model_governance_events WHERE event_id = 'event-rollback-success'" + ) + ).mappings().one() + assert dict(event_row) == { + "action": "model.rollback", + "target_version": "rollback-stable", + "source_version": "rollback-current", + "operator": "admin", + "reason": "controlled rollback acceptance", + } + + +def test_governance_event_failure_rolls_back_model_switch(isolated_engine: sa.Engine): + service = ModelFactService(isolated_engine) + for version in ("event-stable", "event-current"): + service.register_candidate(_candidate(version)) + service.validate_candidate(version, "price", "da_price") + service.activate_model("event-stable", "price", "da_price") + service.activate_model("event-current", "price", "da_price") + + def fail_event(_conn, _cursor, statement, _params, _context, _many): + if "insert into model_governance_events" in " ".join(statement.lower().split()): + raise RuntimeError("injected governance failure") + + event.listen(isolated_engine, "before_cursor_execute", fail_event) + try: + with pytest.raises(ModelFactError, match="事务已回滚"): + service.rollback_active_model( + "event-stable", + "price", + "da_price", + governance_event={ + "event_id": "event-rollback-failed", + "action": "model.rollback", + "operator": "admin", + "reason": "failure injection", + }, + ) + finally: + event.remove(isolated_engine, "before_cursor_execute", fail_event) + + assert service.get_active_model("price", "da_price")["model_version"] == "event-current" + assert service.get_model("event-stable", "price", "da_price")["status"] == "archived" + with isolated_engine.connect() as conn: + assert conn.execute( + text("SELECT COUNT(*) FROM model_governance_events WHERE event_id = 'event-rollback-failed'") + ).scalar_one() == 0 + + +def test_empty_registry_never_falls_back_to_legacy_model_versions(isolated_engine: sa.Engine): + service = ModelFactService(isolated_engine) + assert service.list_models("price", "da_price") == [] + assert service.get_active_model("price", "da_price") == {} + with isolated_engine.connect() as conn: + assert conn.execute(text("SELECT COUNT(*) FROM model_versions")).scalar_one() == 1 + assert conn.execute(text("SELECT COUNT(*) FROM model_registry")).scalar_one() == 0 + + +def test_prediction_active_lookup_uses_registry_without_local_fallback( + isolated_engine: sa.Engine, + monkeypatch: pytest.MonkeyPatch, +): + service = ModelFactService(isolated_engine) + service.register_candidate(_candidate("prediction-active")) + service.validate_candidate("prediction-active", "price", "da_price") + service.activate_model("prediction-active", "price", "da_price") + monkeypatch.setattr(active_model_loader, "get_database_config", lambda _config: SimpleNamespace(enabled=True)) + monkeypatch.setattr(active_model_loader, "create_database_engine", lambda _config: isolated_engine) + config = {"model_learning": {"domain": "price", "target_name": "da_price", "active_artifact_path": "must-not-fallback"}} + assert active_model_loader.get_active_model_record(config)["model_version"] == "prediction-active" + service.deactivate_model("prediction-active", "price", "da_price") + with pytest.raises(active_model_loader.ActiveModelUnavailable, match="不会回退"): + active_model_loader.get_active_model_record(config) + + +def test_legacy_model_sync_is_fail_closed_and_does_not_write(isolated_engine: sa.Engine): + before = _snapshot(isolated_engine) + writes: list[str] = [] + + def record_sql(_conn, _cursor, statement, _params, _context, _many): + verb = statement.lstrip().split(None, 1)[0].upper() if statement.strip() else "" + if verb in {"INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TRUNCATE"}: + writes.append(verb) + + event.listen(isolated_engine, "before_cursor_execute", record_sql) + try: + result = sync_model_facts(engine=isolated_engine) + finally: + event.remove(isolated_engine, "before_cursor_execute", record_sql) + assert result["blocked"] is True + assert result["model_versions_written"] == 0 + assert writes == [] + assert _snapshot(isolated_engine) == before + + +def test_get_list_detail_and_active_100_times_have_no_write_side_effects( + isolated_engine: sa.Engine, + monkeypatch: pytest.MonkeyPatch, +): + service = ModelFactService(isolated_engine) + service.register_candidate(_candidate("read-stable")) + service.validate_candidate("read-stable", "price", "da_price") + service.activate_model("read-stable", "price", "da_price") + service.register_candidate(_candidate("read-candidate")) + before = _snapshot(isolated_engine) + writes: list[str] = [] + + def record_sql(_conn, _cursor, statement, _params, _context, _many): + verb = statement.lstrip().split(None, 1)[0].upper() if statement.strip() else "" + if verb in {"INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TRUNCATE"}: + writes.append(verb) + + event.listen(isolated_engine, "before_cursor_execute", record_sql) + monkeypatch.setattr(model_repository, "postgres_engine", lambda: isolated_engine) + client = TestClient(app) + try: + for _ in range(100): + response = client.get("/api/models/center/overview?domain=price&target_name=da_price") + assert response.status_code == 200 + assert response.json()["data_lineage"]["versions"] == "postgresql.model_registry" + for _ in range(100): + response = client.get("/api/models/center/versions/read-stable?domain=price&target_name=da_price") + assert response.status_code == 200 and response.json()["version"]["status"] == "active" + for _ in range(100): + response = client.get("/api/models/active?domain=price&target_name=da_price") + assert response.status_code == 200 and response.json()["active"]["model_version"] == "read-stable" + finally: + event.remove(isolated_engine, "before_cursor_execute", record_sql) + + assert writes == [] + assert _snapshot(isolated_engine) == before + with isolated_engine.connect() as conn: + assert conn.execute(text("SELECT COUNT(*) FROM model_versions")).scalar_one() == 1 diff --git a/tests/test_t004_security.py b/tests/test_t004_security.py new file mode 100644 index 0000000..fe83c26 --- /dev/null +++ b/tests/test_t004_security.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import asyncio +import json +import os +import time +from pathlib import Path + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from backend.app import observability +from backend.app.api.v1.endpoints import system as system_endpoint +from backend.app.api.v1.endpoints.assistant import _format_messages_for_export, _stream_event +from backend.app.auth.jwt import JWTError, create_access_token, decode_access_token +from backend.app.core import config as config_module +from backend.app.core.config import SecurityConfigurationError, get_settings, reset_settings_cache +from backend.app.core.security import CurrentUser, require_permission +from backend.app.main import app, create_app, sanitized_http_exception_handler + + +ROOT = Path(__file__).resolve().parents[1] +client = TestClient(app) +SECURITY_ENV_KEYS = ( + "APP_ENV", + "ENV", + "AUTH_REQUIRED", + "JWT_SECRET_KEY", + "JWT_ALGORITHM", + "ADMIN_INITIALIZED", + "SECURITY_DATABASE_URL", +) +STRONG_TEST_SECRET = "t004-isolated-test-secret-0123456789abcdef" + + +@pytest.fixture(autouse=True) +def _isolated_security_env(monkeypatch): + monkeypatch.setattr(config_module, "load_dotenv", lambda: None) + for key in SECURITY_ENV_KEYS: + monkeypatch.delenv(key, raising=False) + if os.environ.get("DATABASE_URL"): + monkeypatch.setenv( + "SECURITY_DATABASE_URL", + "postgresql+psycopg://day4_security_test:unit-test-only@127.0.0.1:1/day4_security_test", + ) + reset_settings_cache() + yield + reset_settings_cache() + + +def _production_env(monkeypatch, *, secret: str | None = STRONG_TEST_SECRET, auth: str | None = "1", admin: str | None = "1") -> None: + monkeypatch.setenv("APP_ENV", "production") + monkeypatch.setenv( + "DATABASE_URL", + "postgresql+psycopg://t004_app:unit-test-only@127.0.0.1:1/postgres", + ) + monkeypatch.setenv( + "SECURITY_DATABASE_URL", + "postgresql+psycopg://t004_security:unit-test-only@127.0.0.1:1/postgres", + ) + for name, value in (("JWT_SECRET_KEY", secret), ("AUTH_REQUIRED", auth), ("ADMIN_INITIALIZED", admin)): + if value is None: + monkeypatch.delenv(name, raising=False) + else: + monkeypatch.setenv(name, value) + reset_settings_cache() + + +@pytest.mark.parametrize( + "secret", + [None, "", "change_me", "change_me_dev_jwt_secret", "placeholder-secret-value-that-is-long-enough", "${JWT_SECRET_KEY}"], +) +def test_production_rejects_missing_empty_or_weak_jwt_secret(monkeypatch, secret): + _production_env(monkeypatch, secret=secret) + with pytest.raises(SecurityConfigurationError, match="JWT_SECRET_KEY"): + create_app() + + +def test_production_rejects_disabled_auth(monkeypatch): + _production_env(monkeypatch, auth="0") + with pytest.raises(SecurityConfigurationError, match="AUTH_REQUIRED"): + get_settings() + + +def test_production_rejects_missing_admin_initialization(monkeypatch): + _production_env(monkeypatch, admin=None) + with pytest.raises(SecurityConfigurationError, match="ADMIN_INITIALIZED"): + create_app() + + +def test_production_defaults_auth_on_but_development_and_test_default_off(monkeypatch): + _production_env(monkeypatch, auth=None) + assert get_settings().auth_required is True + for app_env in ("development", "test"): + monkeypatch.setenv("APP_ENV", app_env) + monkeypatch.delenv("AUTH_REQUIRED", raising=False) + monkeypatch.delenv("JWT_SECRET_KEY", raising=False) + monkeypatch.delenv("ADMIN_INITIALIZED", raising=False) + reset_settings_cache() + settings = get_settings() + assert settings.app_env == app_env + assert settings.auth_required is False + + +def test_invalid_and_expired_tokens_are_rejected(monkeypatch): + monkeypatch.setenv("AUTH_REQUIRED", "1") + monkeypatch.setenv("JWT_SECRET_KEY", STRONG_TEST_SECRET) + reset_settings_cache() + token = create_access_token(subject="alice", user_id="u1", role="viewer", extra={"exp": int(time.time()) - 1}) + with pytest.raises(JWTError, match="expired"): + decode_access_token(token) + with pytest.raises(JWTError): + decode_access_token(token[:-2] + "xx") + + +def test_protected_api_returns_401_without_token(monkeypatch): + monkeypatch.setenv("AUTH_REQUIRED", "1") + monkeypatch.setenv("JWT_SECRET_KEY", STRONG_TEST_SECRET) + reset_settings_cache() + response = client.get("/api/security/me") + assert response.status_code == 401 + + +def test_permission_dependency_returns_403_and_allows_admin(): + from starlette.requests import Request + + request = Request({"type": "http", "headers": []}) + dependency = require_permission("security:read") + viewer = CurrentUser(user_id="viewer", username="viewer", role="viewer", permissions=[], auth_mode="test") + with pytest.raises(HTTPException) as denied: + dependency(request, viewer) + assert denied.value.status_code == 403 + + admin = CurrentUser(user_id="admin", username="admin", role="admin", permissions=["*"], auth_mode="test") + assert dependency(request, admin) is admin + + +def test_api_sse_export_and_log_messages_are_redacted(monkeypatch): + secret = "t004-super-secret-value" + token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature123" + db_url = "postgresql://user:db-password@localhost/db" + exc = HTTPException(status_code=400, detail=f"JWT_SECRET_KEY={secret} Authorization: Bearer {token} {db_url}") + response = asyncio.run(sanitized_http_exception_handler(None, exc)) + body = response.body.decode("utf-8") + assert secret not in body and token not in body and "db-password" not in body + + frame = _stream_event("error", {"message": f"password={secret} Bearer {token}"}) + rows = _format_messages_for_export([{"role": "user", "content": f"api_key={secret} {db_url}"}]) + assert secret not in frame and token not in frame + assert secret not in json.dumps(rows, ensure_ascii=False) and "db-password" not in json.dumps(rows, ensure_ascii=False) + + captured: dict[str, object] = {} + + class _Logger: + def warning(self, *args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + + monkeypatch.setattr(observability, "configure_app_logging", lambda: None) + monkeypatch.setattr(observability, "app_logger", lambda: _Logger()) + message = observability.log_suppressed_exception("t004", RuntimeError(f"password={secret} Bearer {token}")) + assert secret not in message and token not in message + assert captured["kwargs"]["exc_info"] is False + + +def test_health_and_isolated_database_health_do_not_regress(monkeypatch): + assert client.get("/api/health").status_code == 200 + monkeypatch.setattr(system_endpoint, "database_runtime_status", lambda: {"active": "postgresql", "message": "isolated-test"}) + response = client.get("/api/db/health") + assert response.status_code == 200 + assert response.json() == {"ok": True, "active": "postgresql", "message": "isolated-test"} + + +def test_frontend_and_deployment_auth_contracts_are_fail_closed(): + context = (ROOT / "frontend/src/context/AuthContext.tsx").read_text(encoding="utf-8") + api = (ROOT / "frontend/src/api.ts").read_text(encoding="utf-8") + auth_api = (ROOT / "frontend/src/services/authApi.ts").read_text(encoding="utf-8") + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + enterprise_compose = (ROOT / "docker-compose.enterprise.yml").read_text(encoding="utf-8") + example = (ROOT / ".env.example").read_text(encoding="utf-8") + + assert "VITE_AUTH_REQUIRED ?? '1'" in context + assert "setLoginRequested(authRequired)" in context + assert "sanitizeErrorMessage" in api + assert "登录响应缺少访问令牌或用户信息" in auth_api + jwt_lines = [line for line in compose.splitlines() if "JWT_SECRET_KEY:" in line] + admin_lines = [line for line in compose.splitlines() if "ADMIN_INITIALIZED:" in line] + assert jwt_lines and all(":?JWT_SECRET_KEY is required for production" in line for line in jwt_lines) + assert admin_lines and all(":?ADMIN_INITIALIZED=1 is required after admin bootstrap" in line for line in admin_lines) + assert "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}" in compose + assert "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}" in enterprise_compose + assert "AUTH_REQUIRED: ${AUTH_REQUIRED:-0}" not in enterprise_compose + assert "POSTGRES_PASSWORD:-postgres" not in enterprise_compose + assert "JWT_SECRET_KEY=change_me" not in example + + +def test_env_docker_requires_external_production_secrets(monkeypatch): + values: dict[str, str] = {} + for raw_line in (ROOT / ".env.docker.example").read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + + assert values.get("APP_ENV") == "production" + assert values.get("AUTH_REQUIRED") == "1" + assert values.get("ADMIN_INITIALIZED") == "1" + assert values.get("JWT_SECRET_KEY", "").startswith("replace_with_") + assert values.get("POSTGRES_PASSWORD", "").startswith("replace_with_") + assert not values.get("ADMIN_PASSWORD") + assert values.get("RAG_ENABLED") == "0" + assert values.get("RAG_EMBEDDING_ALLOW_FALLBACK") == "0" + assert values.get("RAG_FILE_FALLBACK_ENABLED") == "0" + + monkeypatch.setenv("APP_ENV", values["APP_ENV"]) + monkeypatch.setenv("AUTH_REQUIRED", values["AUTH_REQUIRED"]) + monkeypatch.setenv("JWT_SECRET_KEY", values["JWT_SECRET_KEY"]) + monkeypatch.setenv("ADMIN_INITIALIZED", values["ADMIN_INITIALIZED"]) + reset_settings_cache() + with pytest.raises(SecurityConfigurationError, match="JWT_SECRET_KEY"): + create_app() + + +def test_day2_preflight_static_gates_pass(): + from scripts.day2_repro_preflight import _config_checks, _migration_checks + + _, config_failures = _config_checks(ROOT / ".env.docker.example") + migration_result, migration_failures = _migration_checks() + + assert config_failures == [] + assert migration_failures == [] + assert migration_result["files_scanned"] == 24 + assert any( + record["file"] == "0020_enterprise_memory_core_v1.py" + for record in migration_result["records"] + ) + assert any( + record["file"] == "0021_enterprise_memory_lifecycle_v1.py" + for record in migration_result["records"] + ) + assert any( + record["file"] == "0022_chatbi_semantic_analysis_v1.py" + for record in migration_result["records"] + ) + + env_py = (ROOT / "migrations/env.py").read_text(encoding="utf-8") + assert "ALEMBIC_TARGET_SCHEMA" in env_py + assert "version_table_schema" in env_py + assert "SET search_path" in env_py + assert "context.run_migrations()" in env_py diff --git a/tests/test_t005_source_contract.py b/tests/test_t005_source_contract.py new file mode 100644 index 0000000..106ed9a --- /dev/null +++ b/tests/test_t005_source_contract.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +import os +from pathlib import Path +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import inspect, text + +from backend.app.ai_assistant.tools import execute_tool +from backend.app.core.config import reset_settings_cache +from backend.app.db.session import get_engine, reset_db_cache +from backend.app.main import app +from backend.app.repositories.knowledge_repository import ensure_seed_knowledge +from backend.app.source_contract import SourceType, source_meta + + +ROOT = Path(__file__).resolve().parents[1] +EXPECTED_DB = "intelligent_ops_t005_source_contract_test" + + +def _reset_runtime(): + reset_settings_cache() + reset_db_cache() + return get_engine() + + +@pytest.fixture(autouse=True) +def _cleanup_t005_runs(): + """Keep this contract test order-independent inside the shared disposable Schema.""" + + yield + if os.environ.get("BETA10D_TEST_ISOLATION_ACTIVE") != "1": + return + engine = _reset_runtime() + if engine is None: + return + with engine.begin() as connection: + connection.execute(text("DELETE FROM forecast_results WHERE run_id LIKE 't005_%'")) + connection.execute(text("DELETE FROM forecast_runs WHERE run_id LIKE 't005_%'")) + + +def _insert_run(engine, *, status: str, finished_at: datetime, include_results: bool) -> str: + run_id = f"t005_{status}_{uuid4().hex[:12]}" + forecast_start = (finished_at + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) + forecast_end = forecast_start + timedelta(hours=23) + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO forecast_runs ( + run_id, status, domain, target_name, + forecast_start_at, forecast_end_at, + model_id, model_version, feature_version, schema_hash, + result_hash, source_type, generated_at, created_at, updated_at, + started_at, finished_at, record_count, row_count + ) + VALUES ( + :run_id, :status, 'price', 'da_price', + :forecast_start, :forecast_end, + 't005-model-id', 't005-model-v1', 't005-features-v1', 't005-schema-hash', + :result_hash, 'real', :finished_at, :finished_at, :finished_at, + :finished_at, :finished_at, :record_count, :record_count + ) + """ + ), + { + "run_id": run_id, + "status": status, + "forecast_start": forecast_start, + "forecast_end": forecast_end, + "finished_at": finished_at, + "record_count": 24 if include_results else 0, + "result_hash": uuid4().hex if include_results else None, + }, + ) + if include_results: + for index in range(24): + forecast_time = forecast_start + timedelta(hours=index) + conn.execute( + text( + """ + INSERT INTO forecast_results ( + run_id, forecast_datetime, forecast_time, + predicted_price, corrected_predicted_price, + risk_level, spike_risk_prob, source_row, + model_version, feature_version, generated_at, source_type + ) + VALUES ( + :run_id, :forecast_time, :forecast_time, + :price, :price, :risk_level, :risk_probability, :source_row, + 't005-model-v1', 't005-features-v1', :generated_at, 'real' + ) + """ + ), + { + "run_id": run_id, + "forecast_time": forecast_time, + "price": 80.0 + index, + "risk_level": "high" if index >= 20 else "low", + "risk_probability": 0.7 if index >= 20 else 0.1, + "source_row": index + 1, + "generated_at": finished_at, + }, + ) + return run_id + + +def _table_snapshot(engine) -> dict[str, tuple[int, str]]: + candidates = [ + "forecast_runs", + "forecast_results", + "model_registry", + "model_versions", + "report_runs", + "strategy_advice", + "anomaly_explanations", + "audit_logs", + "ai_chat_sessions", + "ai_chat_messages", + "kb_documents", + "kb_chunks", + ] + existing = set(inspect(engine).get_table_names()) + snapshot: dict[str, tuple[int, str]] = {} + with engine.connect() as conn: + for table in candidates: + if table not in existing: + continue + row = conn.execute( + text( + f""" + SELECT COUNT(*) AS rows_count, + MD5(COALESCE(STRING_AGG(ROW_TO_JSON(t)::text, '|' ORDER BY ROW_TO_JSON(t)::text), '')) AS content_hash + FROM "{table}" AS t + """ + ) + ).mappings().one() + snapshot[table] = (int(row["rows_count"]), str(row["content_hash"])) + return snapshot + + +def test_source_type_enum_and_timezone_contract(): + assert {item.value for item in SourceType} == { + "real", + "historical", + "simulated", + "demo", + "seed", + "fallback", + "derived", + "ai_inferred", + "unavailable", + } + for source_type in SourceType: + meta = source_meta(source_type, "electricity_day_ahead_price", generated_at="2026-07-16 12:00:00") + assert meta["source_type"] == source_type.value + assert meta["generated_at"].endswith("Z") + assert isinstance(meta["evidence"], list) + + +@pytest.mark.integration +def test_api_web_ai_share_run_and_all_read_paths_are_side_effect_free(monkeypatch, request): + monkeypatch.setenv("APP_ENV", "test") + monkeypatch.setenv("AUTH_REQUIRED", "0") + monkeypatch.setenv("DATABASE_ALLOW_LEGACY_FALLBACK", "0") + engine = _reset_runtime() + if os.environ.get("BETA10D_TEST_ISOLATION_ACTIVE") == "1": + assert engine.url.database == "postgres" + expected_prefix = os.environ.get("BETA10D_TEST_SCHEMA_PREFIX", "beta10d_day3_close_") + with engine.connect() as connection: + assert str(connection.exec_driver_sql("SELECT current_schema()").scalar_one()).startswith(expected_prefix) + else: + assert engine.url.database == EXPECTED_DB + assert ensure_seed_knowledge(explicit=True).get("available") is True + assert ensure_seed_knowledge().get("blocked") is True + + historical_run = _insert_run( + engine, + status="success", + finished_at=datetime.now(timezone.utc) - timedelta(days=2), + include_results=True, + ) + failed_run = _insert_run( + engine, + status="failed", + finished_at=datetime.now(timezone.utc) - timedelta(hours=1), + include_results=False, + ) + latest_run = _insert_run( + engine, + status="success", + finished_at=datetime.now(timezone.utc), + include_results=True, + ) + + client = TestClient(app) + client.__enter__() + request.addfinalizer(lambda: client.__exit__(None, None, None)) + latest = client.get("/api/forecast/runs/latest-success").json() + context = client.get("/api/source/context").json() + forecast = client.get("/api/forecast/latest").json() + dashboard = client.get("/api/dashboard/summary").json() + ai_latest = execute_tool("get_forecast_metrics", run_id="latest") + ai_historical = execute_tool("get_forecast_metrics", run_id=historical_run) + + assert latest["run"]["run_id"] == latest_run + assert context["meta"]["run_id"] == latest_run + assert forecast["meta"]["run_id"] == latest_run + assert dashboard["meta"]["run_id"] == latest_run + assert ai_latest["meta"]["run_id"] == latest_run + assert ai_latest["meta"]["source_type"] == "derived" + assert ai_historical["meta"]["run_id"] == historical_run + assert ai_historical["meta"]["is_stale"] is True + assert ai_historical["meta"]["stale_reason"] == "historical_run" + + historical = client.get(f"/api/forecast/runs/{historical_run}").json() + failed = client.get(f"/api/forecast/runs/{failed_run}").json() + unavailable = client.get("/api/source/context?run_id=missing-t005-run").json() + wrong_domain = client.get("/api/source/context?domain=load_forecast") + unknown_domain = client.get("/api/source/context?domain=unknown") + knowledge = client.get("/api/knowledge/documents").json() + assert historical["meta"]["source_type"] == "historical" + assert failed["meta"]["source_type"] == "unavailable" + assert failed["meta"]["unavailable_reason"] == "run_not_successful" + assert unavailable["meta"]["source_type"] == "unavailable" + assert unavailable["data"] is None + assert wrong_domain.json()["meta"]["unavailable_reason"] == "domain_not_supported_by_forecast_results" + assert unknown_domain.status_code == 400 + assert all(item.get("source_type") != "seed" for item in knowledge.get("items") or []) + + before = _table_snapshot(engine) + paths = [ + "/api/forecast/runs/latest-success", + f"/api/forecast/runs/{latest_run}", + "/api/models/metrics", + "/api/models/active", + "/api/dashboard/summary", + "/api/knowledge/documents", + ] + for path in paths: + for _ in range(100): + response = client.get(path) + assert response.status_code == 200, (path, response.text[:300]) + for _ in range(100): + tool = execute_tool("get_forecast_metrics", run_id=latest_run) + assert tool["meta"]["run_id"] == latest_run + assert tool["source_type"] == "derived" + after = _table_snapshot(engine) + assert after == before + + +def test_frontend_contract_migrates_fact_status_out_of_global_layout(): + states = (ROOT / "frontend/src/components/common/States.tsx").read_text(encoding="utf-8") + layout = (ROOT / "frontend/src/layout/BasicLayout.tsx").read_text(encoding="utf-8") + settings = (ROOT / "frontend/src/pages/settings/SettingsPage.tsx").read_text(encoding="utf-8") + settings_api = (ROOT / "frontend/src/services/settingsApi.ts").read_text(encoding="utf-8") + api = (ROOT / "frontend/src/api.ts").read_text(encoding="utf-8") + assert "SourceContextPanel" in states + for forbidden_label in ["数据来源", "模拟数据", "真实数据", "测试数据", "Seed 数据"]: + assert forbidden_label not in states + for neutral_label in ["业务时间", "更新时间", "批次状态"]: + assert neutral_label in states + assert "当前业务信息暂不可用" in states + assert "当前无可用真实预测" not in states + assert "FactStatusBar" not in layout + assert "api.sourceContext()" not in layout + assert "SourceContextPanel" in settings + assert "api.sourceContext" in settings_api + assert "/api/source/context" in api diff --git a/tests/test_task_center_api_closure.py b/tests/test_task_center_api_closure.py new file mode 100644 index 0000000..8fc0df5 --- /dev/null +++ b/tests/test_task_center_api_closure.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from backend.app.api.v1.endpoints import task as task_endpoint +from backend.app.main import app + + +client = TestClient(app) + + +def test_task_center_overview_endpoint(monkeypatch): + monkeypatch.setattr( + task_endpoint, + "task_overview", + lambda: { + "task_total": 8, + "success_total": 5, + "running_total": 1, + "pending_total": 1, + "failed_total": 1, + "timeout_total": 0, + "queue_total": 4, + "success_rate": 62.5, + "day_over_day_change": {"task_total": 12.6}, + }, + ) + response = client.get("/api/tasks/overview") + assert response.status_code == 200 + payload = response.json() + assert payload["task_total"] == 8 + assert payload["success_rate"] == 62.5 + + +def test_task_runs_endpoint_supports_filters_and_pagination(monkeypatch): + captured = {} + + def fake_list_task_runs(**kwargs): + captured.update(kwargs) + return {"list": [{"task_id": "task_1"}], "tasks": [{"task_id": "task_1"}], "total": 1, "page": kwargs["page"], "page_size": kwargs["page_size"]} + + monkeypatch.setattr(task_endpoint, "list_task_runs", fake_list_task_runs) + response = client.get("/api/tasks/runs?task_type=price_predict&queue_name=price_predict&keyword=price&page=2&page_size=5") + assert response.status_code == 200 + assert response.json()["total"] == 1 + assert captured["task_type"] == "price_predict" + assert captured["queue_name"] == "price_predict" + assert captured["keyword"] == "price" + assert captured["page"] == 2 + assert captured["page_size"] == 5 + + +def test_recent_logs_retry_queue_trend_endpoints(monkeypatch): + monkeypatch.setattr(task_endpoint, "recent_task_logs", lambda **kwargs: [{"task_id": "task_log", "message": "ok"}]) + monkeypatch.setattr(task_endpoint, "retry_task_candidates", lambda limit=20: [{"task_id": "task_failed"}]) + monkeypatch.setattr(task_endpoint, "queue_overview", lambda: [{"queue_name": "price_predict", "running_count": 1}]) + monkeypatch.setattr(task_endpoint, "task_trend", lambda days=7: [{"date": "2026-06-21", "success_count": 2}]) + + assert client.get("/api/tasks/logs/recent?limit=3").json()["items"][0]["task_id"] == "task_log" + assert client.get("/api/tasks/retry/recent?limit=3").json()["items"][0]["task_id"] == "task_failed" + assert client.get("/api/tasks/queues/overview").json()["items"][0]["queue_name"] == "price_predict" + assert client.get("/api/tasks/trend?days=7").json()["items"][0]["success_count"] == 2 + + +def test_task_start_returns_503_instead_of_fake_postgres_pending(monkeypatch): + monkeypatch.setattr(task_endpoint, "enqueue_task", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("celery unavailable"))) + monkeypatch.setattr(task_endpoint, "write_audit_log", lambda **kwargs: None) + + response = client.post("/api/tasks/start", json={"task_type": "price_predict", "queue_name": "price_predict", "payload": {}}) + assert response.status_code == 503 + assert response.json()["detail"] == "celery unavailable" + + +def test_task_start_dispatches_business_task_types(monkeypatch): + captured: list[tuple[str, dict]] = [] + + def fake_enqueue(kind, payload=None): + captured.append((kind, payload or {})) + return {"task_id": f"task_{kind}", "status": "pending", "kind": kind, "payload": payload or {}} + + monkeypatch.setattr(task_endpoint, "enqueue_task", fake_enqueue) + monkeypatch.setattr(task_endpoint, "write_audit_log", lambda **kwargs: None) + + for kind in ["price_predict", "data_sync", "report_daily", "health_check"]: + response = client.post("/api/tasks/start", json={"task_type": kind, "payload": {"source": "test"}}) + assert response.status_code == 200 + assert response.json()["kind"] == kind + + assert [item[0] for item in captured] == [ + "price_predict", + "data_sync", + "report_daily", + "health_check", + ] + + +def test_task_retry_returns_503_instead_of_fake_postgres_pending(monkeypatch): + monkeypatch.setattr( + task_endpoint, + "get_task_record", + lambda task_id: {"task_id": task_id, "kind": "price_predict", "status": "failed", "retry_count": 0, "max_retries": 2, "payload": {}, "queue_name": "price_predict"}, + ) + monkeypatch.setattr(task_endpoint, "enqueue_task", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("celery unavailable"))) + monkeypatch.setattr(task_endpoint, "append_task_log", lambda *args, **kwargs: True) + monkeypatch.setattr(task_endpoint, "write_audit_log", lambda **kwargs: None) + + response = client.post("/api/tasks/task_failed/retry", json={}) + assert response.status_code == 503 + assert response.json()["detail"] == "celery unavailable" + + +def test_task_detail_and_logs_return_repository_payload(monkeypatch): + monkeypatch.setattr( + task_endpoint, + "get_task_record", + lambda task_id: { + "task_id": task_id, + "kind": "price_predict", + "status": "success", + "payload_json": {"horizon": 24}, + "result_json": {"run_id": "forecast_1"}, + }, + ) + monkeypatch.setattr( + task_endpoint, + "list_task_log_entries", + lambda task_id, page=1, page_size=50: { + "task_id": task_id, + "page": page, + "page_size": page_size, + "total": 1, + "items": [{"level": "info", "step": "persist", "message": "done"}], + }, + ) + + detail = client.get("/api/tasks/task_price_1") + logs = client.get("/api/tasks/task_price_1/logs") + assert detail.status_code == 200 + assert detail.json()["result_json"]["run_id"] == "forecast_1" + assert logs.status_code == 200 + assert logs.json()["items"][0]["step"] == "persist" diff --git a/tests/test_task_dispatcher.py b/tests/test_task_dispatcher.py new file mode 100644 index 0000000..36ce21f --- /dev/null +++ b/tests/test_task_dispatcher.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from starlette.requests import Request + +from backend.app.api.v1.endpoints import forecast as forecast_endpoint +from backend.app.core import config +from backend.app.core.security import CurrentUser +from backend.app.schemas import ForecastRunRequest +from backend.app.workers import dispatcher + + +def test_celery_mode_requires_celery(monkeypatch): + monkeypatch.setenv("TASK_EXECUTION_MODE", "celery") + config.reset_settings_cache() + monkeypatch.setattr(dispatcher, "celery_available", lambda: False) + try: + try: + dispatcher.enqueue_task("knowledge_import", {}) + except RuntimeError as exc: + assert "TASK_EXECUTION_MODE=celery" in str(exc) + else: + raise AssertionError("expected RuntimeError") + finally: + monkeypatch.delenv("TASK_EXECUTION_MODE", raising=False) + config.reset_settings_cache() + + +def test_auto_mode_requires_live_queue_without_creating_pending_record(monkeypatch): + monkeypatch.setenv("TASK_EXECUTION_MODE", "auto") + config.reset_settings_cache() + saved: list[dict] = [] + monkeypatch.setattr(dispatcher, "celery_queue_available", lambda _queue: False) + monkeypatch.setattr( + dispatcher, "save_task_record", lambda *args, **kwargs: saved.append(kwargs) or True + ) + try: + with pytest.raises(RuntimeError, match="TASK_EXECUTION_MODE=auto"): + dispatcher.enqueue_task("price_predict", {}) + finally: + monkeypatch.delenv("TASK_EXECUTION_MODE", raising=False) + config.reset_settings_cache() + + assert saved == [] + + +def test_local_thread_mode_uses_specialized_thread(monkeypatch): + monkeypatch.setenv("TASK_EXECUTION_MODE", "local_thread") + config.reset_settings_cache() + monkeypatch.setattr(dispatcher, "celery_available", lambda: True) + monkeypatch.setattr(dispatcher, "save_task_record", lambda *args, **kwargs: True) + + class FakeThread: + def __init__(self, target, args=(), daemon=False): + self.target = target + self.args = args + self.daemon = daemon + + def start(self): + return None + + monkeypatch.setattr(dispatcher.threading, "Thread", FakeThread) + try: + result = dispatcher.enqueue_task("embedding_refresh", {}) + assert result["execution_mode"] == "local_thread" + assert result["status"] == "pending" + finally: + monkeypatch.delenv("TASK_EXECUTION_MODE", raising=False) + config.reset_settings_cache() + + +def test_celery_mode_rejects_missing_target_queue_before_record_creation(monkeypatch): + monkeypatch.setenv("TASK_EXECUTION_MODE", "celery") + monkeypatch.setenv("FORECAST_CELERY_QUEUE", "forecast_final_rc") + config.reset_settings_cache() + saved: list[dict] = [] + monkeypatch.setattr(dispatcher, "celery_queue_available", lambda queue: False) + monkeypatch.setattr( + dispatcher, "save_task_record", lambda *args, **kwargs: saved.append(kwargs) or True + ) + try: + with pytest.raises(RuntimeError, match="forecast_final_rc"): + dispatcher.enqueue_task("today_analysis", {}) + finally: + monkeypatch.delenv("TASK_EXECUTION_MODE", raising=False) + monkeypatch.delenv("FORECAST_CELERY_QUEUE", raising=False) + config.reset_settings_cache() + + assert saved == [] + + +def test_celery_mode_dispatches_to_verified_forecast_queue(monkeypatch): + from backend.app.workers import tasks + + monkeypatch.setenv("TASK_EXECUTION_MODE", "celery") + monkeypatch.setenv("FORECAST_CELERY_QUEUE", "forecast_final_rc") + config.reset_settings_cache() + submitted: list[dict] = [] + monkeypatch.setattr( + dispatcher, + "celery_queue_available", + lambda queue: queue == "forecast_final_rc", + ) + monkeypatch.setattr( + dispatcher, "find_active_idempotent_task", lambda *args, **kwargs: None + ) + monkeypatch.setattr(dispatcher, "save_task_record", lambda *args, **kwargs: True) + monkeypatch.setattr(dispatcher, "append_task_log", lambda *args, **kwargs: True) + monkeypatch.setattr( + tasks.run_command_task, + "apply_async", + lambda *args, **kwargs: submitted.append(kwargs) + or SimpleNamespace(id="celery-test-id"), + ) + try: + result = dispatcher.enqueue_task("today_analysis", {}) + finally: + monkeypatch.delenv("TASK_EXECUTION_MODE", raising=False) + monkeypatch.delenv("FORECAST_CELERY_QUEUE", raising=False) + config.reset_settings_cache() + + assert result["queue_name"] == "forecast_final_rc" + assert result["run_id"].startswith("run_") + assert result["celery_task_id"] == "celery-test-id" + assert result["status"] == "queued" + assert submitted[0]["queue"] == "forecast_final_rc" + + +def test_health_check_dispatches_to_real_worker_lifecycle_task(monkeypatch): + from backend.app.workers import tasks + + monkeypatch.setenv("TASK_EXECUTION_MODE", "auto") + config.reset_settings_cache() + submitted: list[dict] = [] + saved: list[tuple[str, dict]] = [] + monkeypatch.setattr( + dispatcher, + "celery_queue_available", + lambda queue: queue == "default", + ) + monkeypatch.setattr( + dispatcher, "find_active_idempotent_task", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + dispatcher, + "save_task_record", + lambda record, *, status, **kwargs: saved.append((status, dict(record))) or True, + ) + monkeypatch.setattr(dispatcher, "append_task_log", lambda *args, **kwargs: True) + monkeypatch.setattr( + tasks.health_check_task, + "apply_async", + lambda *args, **kwargs: submitted.append({"args": args, **kwargs}) + or SimpleNamespace(id="celery-health-id"), + ) + try: + result = dispatcher.enqueue_task( + "health_check", {"force_new": True, "idempotency_key": "worker-probe-1"} + ) + finally: + monkeypatch.delenv("TASK_EXECUTION_MODE", raising=False) + config.reset_settings_cache() + + assert result["status"] == "queued" + assert result["celery_task_id"] == "celery-health-id" + assert result["queue_name"] == "default" + assert result["run_id"].startswith("run_") + assert submitted[0]["queue"] == "default" + assert len(submitted[0]["args"]) == 3 + assert [status for status, _record in saved] == ["pending", "queued"] + + +def test_health_check_task_keeps_probe_compatibility_and_persists_managed_runs( + monkeypatch, +): + from backend.app.workers import tasks + + lightweight = tasks.health_check_task() + assert lightweight["status"] == "ok" + assert lightweight["worker_id"] + + captured: dict = {} + + def fake_run_python_task(**kwargs): + captured.update(kwargs) + return {"task_id": kwargs["task_id"], "status": "success"} + + monkeypatch.setattr(tasks, "_run_python_task", fake_run_python_task) + managed = tasks.health_check_task( + "task_worker_probe", "run_worker_probe", {"acceptance": True} + ) + + assert managed == {"task_id": "task_worker_probe", "status": "success"} + assert captured["kind"] == "health_check" + assert captured["payload"] == {"acceptance": True} + + +def test_forecast_endpoint_returns_503_when_worker_queue_is_unavailable(monkeypatch): + monkeypatch.setattr( + forecast_endpoint, + "enqueue_task", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("queue unavailable")), + ) + request = Request( + { + "type": "http", + "method": "POST", + "path": "/api/forecast/run", + "headers": [], + "query_string": b"", + "client": ("127.0.0.1", 12345), + "server": ("127.0.0.1", 8000), + "scheme": "http", + } + ) + user = CurrentUser( + user_id="analyst-id", + username="analyst", + role="analyst", + permissions=["forecast:run"], + auth_mode="unit_test", + ) + + with pytest.raises(HTTPException) as exc_info: + forecast_endpoint.run_forecast( + ForecastRunRequest(mode="refresh_fast_forecast"), request, user + ) + + assert exc_info.value.status_code == 503 + assert "队列当前不可用" in str(exc_info.value.detail) + + +@pytest.mark.parametrize("mode", ["fast_forecast", "refresh_fast_forecast"]) +def test_forecast_endpoint_routes_formal_worker_and_preserves_idempotency( + monkeypatch, mode +): + submitted: list[tuple[str, dict]] = [] + monkeypatch.setattr( + forecast_endpoint, + "enqueue_task", + lambda kind, payload: submitted.append((kind, payload)) + or {"task_id": "task_formal", "status": "pending"}, + ) + monkeypatch.setattr(forecast_endpoint, "write_audit_log", lambda **kwargs: None) + request = Request( + { + "type": "http", + "method": "POST", + "path": "/api/forecast/run", + "headers": [], + "query_string": b"", + "client": ("127.0.0.1", 12345), + "server": ("127.0.0.1", 8000), + "scheme": "http", + } + ) + user = CurrentUser( + user_id="analyst-id", + username="analyst", + role="analyst", + permissions=["forecast:run"], + auth_mode="unit_test", + ) + + result = forecast_endpoint.run_forecast( + ForecastRunRequest( + mode=mode, + idempotency_key="forecast-request-001", + ), + request, + user, + ) + + assert result["task_id"] == "task_formal" + assert submitted == [ + ("forecast_run", {"idempotency_key": "forecast-request-001"}) + ] diff --git "a/\351\253\230\345\263\260\345\260\226\345\210\272\345\242\236\345\274\272\347\211\210_\347\224\265\345\212\233\345\270\202\345\234\272\347\224\265\344\273\267\351\242\204\346\265\213\344\270\216\346\231\272\350\203\275\345\210\206\346\236\220\347\263\273\347\273\237_v4_fix1.py" "b/\351\253\230\345\263\260\345\260\226\345\210\272\345\242\236\345\274\272\347\211\210_\347\224\265\345\212\233\345\270\202\345\234\272\347\224\265\344\273\267\351\242\204\346\265\213\344\270\216\346\231\272\350\203\275\345\210\206\346\236\220\347\263\273\347\273\237_v4_fix1.py" index 7cb9d8a..a8626b0 100644 --- "a/\351\253\230\345\263\260\345\260\226\345\210\272\345\242\236\345\274\272\347\211\210_\347\224\265\345\212\233\345\270\202\345\234\272\347\224\265\344\273\267\351\242\204\346\265\213\344\270\216\346\231\272\350\203\275\345\210\206\346\236\220\347\263\273\347\273\237_v4_fix1.py" +++ "b/\351\253\230\345\263\260\345\260\226\345\210\272\345\242\236\345\274\272\347\211\210_\347\224\265\345\212\233\345\270\202\345\234\272\347\224\265\344\273\267\351\242\204\346\265\213\344\270\216\346\231\272\350\203\275\345\210\206\346\236\220\347\263\273\347\273\237_v4_fix1.py" @@ -54,8 +54,8 @@ # 路径配置 # ========================= PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) -DATA_DIR = os.path.join(PROJECT_ROOT, "output") -RESULT_DIR = os.path.join(PROJECT_ROOT, "结果-3") +DATA_DIR = os.environ.get("PIPELINE_DATA_DIR", "").strip() or os.path.join(PROJECT_ROOT, "output") +RESULT_DIR = os.environ.get("PIPELINE_RESULT_DIR", "").strip() or os.path.join(PROJECT_ROOT, "结果-3") MASTER_FILE = os.path.join(DATA_DIR, "master_table.xlsx") FORECAST_LOAD_SELECTED_FILE = os.path.join(DATA_DIR, "forecast_load_selected.xlsx") @@ -441,10 +441,18 @@ def add_safe_business_features(df: pd.DataFrame) -> pd.DataFrame: if "da_price_hist_hour_mean" in df.columns and "da_price_lag_1" in df.columns: df["hourly_pressure_proxy"] = df["da_price_lag_1"] - df["da_price_hist_hour_mean"] - # 误差记忆类特征:训练主表无外部误差记忆时使用安全零值占位,确保快速预测和训练 schema 一致。 + # 误差记忆类特征:只沿时间轴继承最近一个已知历史值。训练主表完全没有 + # 外部误差记忆时使用显式零基线;未来行不得因为 concat 产生 NaN 后再被 + # 模型或调用方静默补值。 for col in ["hour_bias_mean", "scenario_mae", "historical_under_predict_rate"]: if col not in df.columns: df[col] = 0.0 + continue + numeric_values = pd.to_numeric(df[col], errors="coerce") + illegal_mask = df[col].notna() & numeric_values.isna() + if illegal_mask.any(): + raise ValueError(f"ILLEGAL_ERROR_MEMORY: {col} 包含非数值误差记忆") + df[col] = numeric_values.ffill().fillna(0.0) return df @@ -762,6 +770,8 @@ def generate_formal_forward_forecast( raise ValueError(f"NAN_FORBIDDEN: 未来特征包含空值,禁止默认填充:{null_features[:20]}") if not np.isfinite(feature_input.to_numpy(dtype=float)).all(): raise ValueError("INF_FORBIDDEN: 未来特征包含 Inf 或 -Inf") + missing_features: List[str] = [] + remaining_missing: List[str] = [] future_X = pd.DataFrame([feature_input.to_numpy(dtype="float64")], columns=feature_cols, dtype="float64") base_pred = float(artifacts["final_base_model"].predict(future_X)[0])