@kernel-sig/console 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +50 -0
- package/LICENSE +127 -0
- package/Makefile +66 -0
- package/README.md +279 -0
- package/backend/.dockerignore +12 -0
- package/backend/Dockerfile +43 -0
- package/backend/alembic/env.py +75 -0
- package/backend/alembic/script.py.mako +25 -0
- package/backend/alembic/versions/0001_initial.py +88 -0
- package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
- package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
- package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
- package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
- package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
- package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
- package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
- package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
- package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
- package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
- package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
- package/backend/alembic.ini +40 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/deps.py +74 -0
- package/backend/app/api/v1/__init__.py +0 -0
- package/backend/app/api/v1/ai.py +386 -0
- package/backend/app/api/v1/analytics.py +48 -0
- package/backend/app/api/v1/attention.py +142 -0
- package/backend/app/api/v1/auth.py +106 -0
- package/backend/app/api/v1/classification.py +192 -0
- package/backend/app/api/v1/health.py +29 -0
- package/backend/app/api/v1/issues.py +143 -0
- package/backend/app/api/v1/pulls.py +388 -0
- package/backend/app/api/v1/repositories.py +273 -0
- package/backend/app/api/v1/router.py +32 -0
- package/backend/app/api/v1/sig.py +565 -0
- package/backend/app/api/v1/users.py +87 -0
- package/backend/app/api/v1/webhooks.py +135 -0
- package/backend/app/core/__init__.py +0 -0
- package/backend/app/core/config.py +72 -0
- package/backend/app/core/crypto.py +74 -0
- package/backend/app/core/db.py +79 -0
- package/backend/app/core/exceptions.py +60 -0
- package/backend/app/core/logging.py +69 -0
- package/backend/app/core/permissions.py +87 -0
- package/backend/app/core/queue.py +57 -0
- package/backend/app/core/security.py +69 -0
- package/backend/app/domain/__init__.py +0 -0
- package/backend/app/domain/attention.py +618 -0
- package/backend/app/domain/classification.py +689 -0
- package/backend/app/domain/meeting.py +408 -0
- package/backend/app/domain/release.py +163 -0
- package/backend/app/domain/review.py +416 -0
- package/backend/app/domain/sig.py +178 -0
- package/backend/app/domain/sig_info.py +201 -0
- package/backend/app/integrations/__init__.py +0 -0
- package/backend/app/integrations/atomgit/__init__.py +0 -0
- package/backend/app/integrations/atomgit/client.py +505 -0
- package/backend/app/integrations/atomgit/models.py +311 -0
- package/backend/app/integrations/llm/__init__.py +0 -0
- package/backend/app/integrations/llm/prompts.py +222 -0
- package/backend/app/integrations/llm/provider.py +326 -0
- package/backend/app/main.py +242 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/audit.py +129 -0
- package/backend/app/middleware/request_context.py +42 -0
- package/backend/app/models/__init__.py +112 -0
- package/backend/app/models/ai.py +214 -0
- package/backend/app/models/attention.py +131 -0
- package/backend/app/models/attention_settings.py +61 -0
- package/backend/app/models/audit.py +53 -0
- package/backend/app/models/base.py +35 -0
- package/backend/app/models/classification.py +144 -0
- package/backend/app/models/credential.py +56 -0
- package/backend/app/models/issue.py +132 -0
- package/backend/app/models/meeting.py +189 -0
- package/backend/app/models/pull_request.py +288 -0
- package/backend/app/models/repository.py +196 -0
- package/backend/app/models/sig.py +194 -0
- package/backend/app/models/user.py +44 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/ai.py +120 -0
- package/backend/app/schemas/attention.py +43 -0
- package/backend/app/schemas/auth.py +26 -0
- package/backend/app/schemas/classification.py +58 -0
- package/backend/app/schemas/common.py +43 -0
- package/backend/app/schemas/pull_request.py +214 -0
- package/backend/app/schemas/repository.py +94 -0
- package/backend/app/schemas/sig.py +246 -0
- package/backend/app/schemas/user.py +79 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/ai_service.py +569 -0
- package/backend/app/services/analytics_service.py +390 -0
- package/backend/app/services/attention_queue.py +451 -0
- package/backend/app/services/attention_service.py +432 -0
- package/backend/app/services/auth_service.py +74 -0
- package/backend/app/services/classification_service.py +658 -0
- package/backend/app/services/credential_service.py +105 -0
- package/backend/app/services/pull_query.py +273 -0
- package/backend/app/services/release_service.py +446 -0
- package/backend/app/services/repository_service.py +132 -0
- package/backend/app/services/sig_service.py +385 -0
- package/backend/app/services/sync_service.py +752 -0
- package/backend/app/services/user_service.py +68 -0
- package/backend/app/worker.py +389 -0
- package/backend/entrypoint.sh +10 -0
- package/backend/pyproject.toml +68 -0
- package/backend/tests/test_analysis_api.py +154 -0
- package/backend/tests/test_atomgit_client.py +360 -0
- package/backend/tests/test_atomgit_models.py +257 -0
- package/backend/tests/test_attention.py +291 -0
- package/backend/tests/test_audit_middleware.py +135 -0
- package/backend/tests/test_classification.py +498 -0
- package/backend/tests/test_config.py +41 -0
- package/backend/tests/test_crypto.py +68 -0
- package/backend/tests/test_exceptions.py +62 -0
- package/backend/tests/test_health.py +63 -0
- package/backend/tests/test_llm_provider.py +320 -0
- package/backend/tests/test_meeting_domain.py +169 -0
- package/backend/tests/test_permissions.py +69 -0
- package/backend/tests/test_pull_query_wiring.py +66 -0
- package/backend/tests/test_pull_schemas.py +82 -0
- package/backend/tests/test_release_domain.py +82 -0
- package/backend/tests/test_review_parser.py +301 -0
- package/backend/tests/test_schemas_user.py +97 -0
- package/backend/tests/test_security.py +92 -0
- package/cli/index.js +338 -0
- package/compose.yaml +105 -0
- package/frontend/.dockerignore +4 -0
- package/frontend/Dockerfile +27 -0
- package/frontend/index.html +14 -0
- package/frontend/nginx.conf +47 -0
- package/frontend/package-lock.json +5020 -0
- package/frontend/package.json +35 -0
- package/frontend/src/api/ai.ts +124 -0
- package/frontend/src/api/analytics.ts +66 -0
- package/frontend/src/api/attention.ts +181 -0
- package/frontend/src/api/auth.ts +74 -0
- package/frontend/src/api/classification.ts +170 -0
- package/frontend/src/api/issues.ts +90 -0
- package/frontend/src/api/pulls.ts +233 -0
- package/frontend/src/api/repositories.ts +95 -0
- package/frontend/src/api/sig.ts +232 -0
- package/frontend/src/app/antd-theme.ts +94 -0
- package/frontend/src/app/providers.tsx +59 -0
- package/frontend/src/app/router.tsx +411 -0
- package/frontend/src/app/search.ts +30 -0
- package/frontend/src/components/ClassificationBadge.tsx +58 -0
- package/frontend/src/components/GateBadge.tsx +13 -0
- package/frontend/src/components/SeverityBadge.tsx +20 -0
- package/frontend/src/components/layout/AppShell.tsx +16 -0
- package/frontend/src/components/layout/AuthLayout.tsx +32 -0
- package/frontend/src/components/layout/Sidebar.tsx +223 -0
- package/frontend/src/components/layout/TopBar.tsx +47 -0
- package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
- package/frontend/src/components/pulls/FacetRail.tsx +199 -0
- package/frontend/src/components/pulls/LabelChips.tsx +87 -0
- package/frontend/src/components/ui/alert.tsx +30 -0
- package/frontend/src/components/ui/badge.tsx +53 -0
- package/frontend/src/components/ui/button.tsx +51 -0
- package/frontend/src/components/ui/card.tsx +64 -0
- package/frontend/src/components/ui/chart-theme.ts +65 -0
- package/frontend/src/components/ui/data-table.tsx +39 -0
- package/frontend/src/components/ui/echart.tsx +70 -0
- package/frontend/src/components/ui/empty-state.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +39 -0
- package/frontend/src/components/ui/lazy-chart.tsx +21 -0
- package/frontend/src/components/ui/skeleton.tsx +19 -0
- package/frontend/src/hooks/use-current-repository.ts +44 -0
- package/frontend/src/hooks/use-current-user.ts +38 -0
- package/frontend/src/lib/api-client.ts +93 -0
- package/frontend/src/lib/css-color.ts +60 -0
- package/frontend/src/lib/utils.ts +45 -0
- package/frontend/src/main.tsx +22 -0
- package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
- package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
- package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
- package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
- package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
- package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
- package/frontend/src/pages/login/LoginPage.tsx +95 -0
- package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
- package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
- package/frontend/src/pages/members/MembersPage.tsx +534 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
- package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
- package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
- package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
- package/frontend/src/pages/setup/SetupPage.tsx +144 -0
- package/frontend/src/styles/globals.css +147 -0
- package/frontend/tsconfig.json +22 -0
- package/frontend/vite.config.ts +57 -0
- package/package.json +48 -0
- package/scripts/e2e-auth-flow.py +131 -0
- package/scripts/e2e-pr-detail.py +128 -0
- package/scripts/e2e-verify.py +773 -0
- package/scripts/verify-ai-pipeline.py +616 -0
- package/scripts/verify-analysis-pipeline.py +303 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""LLM 提供方抽象。
|
|
2
|
+
|
|
3
|
+
统一走 OpenAI 兼容的 /chat/completions 协议,即可对接 DeepSeek、通义千问、
|
|
4
|
+
智谱、Kimi、OpenAI 以及本地 vLLM / Ollama —— 换供应商只需改 base_url 与模型名。
|
|
5
|
+
|
|
6
|
+
不依赖任何内部模块,便于独立测试与复用。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import random
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
import structlog
|
|
21
|
+
|
|
22
|
+
logger = structlog.get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
|
|
25
|
+
MAX_ATTEMPTS = 3
|
|
26
|
+
BASE_BACKOFF_SECONDS = 1.5
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LLMError(Exception):
|
|
30
|
+
"""LLM 调用异常基类。"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LLMAuthError(LLMError):
|
|
34
|
+
"""凭据无效或权限不足。"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LLMRateLimitError(LLMError):
|
|
38
|
+
"""触发上游限流。"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LLMUpstreamError(LLMError):
|
|
42
|
+
"""上游服务错误(5xx 或网络故障)。"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LLMResponseError(LLMError):
|
|
46
|
+
"""响应内容不符合预期(如要求 JSON 却返回了非 JSON)。"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ChatMessage:
|
|
51
|
+
role: str # system | user | assistant
|
|
52
|
+
content: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class CompletionRequest:
|
|
57
|
+
messages: list[ChatMessage]
|
|
58
|
+
model: str
|
|
59
|
+
temperature: float = 0.1
|
|
60
|
+
max_tokens: int = 4096
|
|
61
|
+
# 要求上游返回 JSON。并非所有供应商都支持,不支持时由 prompt 兜底。
|
|
62
|
+
json_mode: bool = False
|
|
63
|
+
timeout: float = 180.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class CompletionResult:
|
|
68
|
+
content: str
|
|
69
|
+
model: str
|
|
70
|
+
prompt_tokens: int = 0
|
|
71
|
+
completion_tokens: int = 0
|
|
72
|
+
finish_reason: str | None = None
|
|
73
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def total_tokens(self) -> int:
|
|
77
|
+
return self.prompt_tokens + self.completion_tokens
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LLMProvider(Protocol):
|
|
81
|
+
"""任意模型提供方需实现的接口。"""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
|
|
85
|
+
async def complete(self, request: CompletionRequest) -> CompletionResult: ...
|
|
86
|
+
|
|
87
|
+
def stream(self, request: CompletionRequest) -> AsyncIterator[str]: ...
|
|
88
|
+
|
|
89
|
+
async def aclose(self) -> None: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# OpenAI 兼容实现
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class OpenAICompatibleProvider:
|
|
98
|
+
"""OpenAI 兼容协议的客户端。
|
|
99
|
+
|
|
100
|
+
这是目前唯一需要的实现 —— DeepSeek、Qwen、GLM、Kimi、OpenAI、
|
|
101
|
+
vLLM、Ollama 均提供该协议。若将来遇到不兼容的供应商,
|
|
102
|
+
只需新增一个实现 ``LLMProvider`` 的类,上层无需改动。
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
name: str,
|
|
108
|
+
base_url: str,
|
|
109
|
+
api_key: str,
|
|
110
|
+
*,
|
|
111
|
+
default_model: str = "",
|
|
112
|
+
timeout: float = 180.0,
|
|
113
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
114
|
+
max_concurrency: int = 3,
|
|
115
|
+
) -> None:
|
|
116
|
+
if not base_url:
|
|
117
|
+
raise ValueError("必须提供 base_url")
|
|
118
|
+
self.name = name
|
|
119
|
+
self._default_model = default_model
|
|
120
|
+
self._client = httpx.AsyncClient(
|
|
121
|
+
base_url=base_url.rstrip("/"),
|
|
122
|
+
timeout=timeout,
|
|
123
|
+
headers={
|
|
124
|
+
# 各家兼容实现的鉴权头基本一致;未配置 key 时省略该头以支持本地服务
|
|
125
|
+
**({"Authorization": f"Bearer {api_key}"} if api_key else {}),
|
|
126
|
+
"Content-Type": "application/json",
|
|
127
|
+
},
|
|
128
|
+
transport=transport,
|
|
129
|
+
follow_redirects=True,
|
|
130
|
+
)
|
|
131
|
+
# 并发上限:多数供应商按并发限流,串行化比触发 429 再退避更划算
|
|
132
|
+
self._semaphore = asyncio.Semaphore(max_concurrency)
|
|
133
|
+
self.requests = 0
|
|
134
|
+
self.retries = 0
|
|
135
|
+
self.errors = 0
|
|
136
|
+
|
|
137
|
+
async def __aenter__(self) -> OpenAICompatibleProvider:
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
141
|
+
await self.aclose()
|
|
142
|
+
|
|
143
|
+
async def aclose(self) -> None:
|
|
144
|
+
await self._client.aclose()
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _payload(self, request: CompletionRequest, *, stream: bool) -> dict[str, Any]:
|
|
149
|
+
payload: dict[str, Any] = {
|
|
150
|
+
"model": request.model or self._default_model,
|
|
151
|
+
"messages": [
|
|
152
|
+
{"role": message.role, "content": message.content} for message in request.messages
|
|
153
|
+
],
|
|
154
|
+
"temperature": request.temperature,
|
|
155
|
+
"max_tokens": request.max_tokens,
|
|
156
|
+
"stream": stream,
|
|
157
|
+
}
|
|
158
|
+
if request.json_mode:
|
|
159
|
+
payload["response_format"] = {"type": "json_object"}
|
|
160
|
+
return payload
|
|
161
|
+
|
|
162
|
+
async def complete(self, request: CompletionRequest) -> CompletionResult:
|
|
163
|
+
payload = self._payload(request, stream=False)
|
|
164
|
+
response = await self._post("/chat/completions", payload, request.timeout)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
body = response.json()
|
|
168
|
+
choice = body["choices"][0]
|
|
169
|
+
usage = body.get("usage") or {}
|
|
170
|
+
except (ValueError, KeyError, IndexError) as exc:
|
|
171
|
+
raise LLMResponseError(f"响应结构不符合预期:{exc}") from exc
|
|
172
|
+
|
|
173
|
+
return CompletionResult(
|
|
174
|
+
content=(choice.get("message") or {}).get("content") or "",
|
|
175
|
+
model=body.get("model") or payload["model"],
|
|
176
|
+
prompt_tokens=usage.get("prompt_tokens", 0),
|
|
177
|
+
completion_tokens=usage.get("completion_tokens", 0),
|
|
178
|
+
finish_reason=choice.get("finish_reason"),
|
|
179
|
+
raw=body,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def stream(self, request: CompletionRequest) -> AsyncIterator[str]:
|
|
183
|
+
"""流式输出。用于前端 SSE 实时展示分析过程。"""
|
|
184
|
+
payload = self._payload(request, stream=True)
|
|
185
|
+
async with (
|
|
186
|
+
self._semaphore,
|
|
187
|
+
self._client.stream(
|
|
188
|
+
"POST", "/chat/completions", json=payload, timeout=request.timeout
|
|
189
|
+
) as response,
|
|
190
|
+
):
|
|
191
|
+
self.requests += 1
|
|
192
|
+
if response.status_code >= 400:
|
|
193
|
+
await response.aread()
|
|
194
|
+
self._raise_for_status(response)
|
|
195
|
+
async for line in response.aiter_lines():
|
|
196
|
+
if not line.startswith("data:"):
|
|
197
|
+
continue
|
|
198
|
+
data = line[5:].strip()
|
|
199
|
+
if data == "[DONE]":
|
|
200
|
+
return
|
|
201
|
+
try:
|
|
202
|
+
chunk = json.loads(data)
|
|
203
|
+
delta = chunk["choices"][0].get("delta") or {}
|
|
204
|
+
if content := delta.get("content"):
|
|
205
|
+
yield content
|
|
206
|
+
except (ValueError, KeyError, IndexError):
|
|
207
|
+
# 个别供应商会插入心跳或注释行,忽略即可
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
async def _post(self, path: str, payload: dict[str, Any], timeout: float) -> httpx.Response:
|
|
211
|
+
last_error: LLMError | None = None
|
|
212
|
+
|
|
213
|
+
async with self._semaphore:
|
|
214
|
+
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
215
|
+
self.requests += 1
|
|
216
|
+
try:
|
|
217
|
+
response = await self._client.post(path, json=payload, timeout=timeout)
|
|
218
|
+
except httpx.HTTPError as exc:
|
|
219
|
+
last_error = LLMUpstreamError(f"网络请求失败:{exc}")
|
|
220
|
+
if attempt == MAX_ATTEMPTS:
|
|
221
|
+
break
|
|
222
|
+
await self._backoff(attempt)
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
if response.status_code in RETRYABLE_STATUS:
|
|
226
|
+
detail = _error_detail(response)
|
|
227
|
+
last_error = (
|
|
228
|
+
LLMRateLimitError(f"触发限流:{detail}")
|
|
229
|
+
if response.status_code == 429
|
|
230
|
+
else LLMUpstreamError(f"上游返回 {response.status_code}:{detail}")
|
|
231
|
+
)
|
|
232
|
+
if attempt == MAX_ATTEMPTS:
|
|
233
|
+
break
|
|
234
|
+
self.retries += 1
|
|
235
|
+
await self._backoff(attempt, response)
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
self._raise_for_status(response)
|
|
239
|
+
return response
|
|
240
|
+
|
|
241
|
+
self.errors += 1
|
|
242
|
+
assert last_error is not None
|
|
243
|
+
raise last_error
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
247
|
+
code = response.status_code
|
|
248
|
+
if code < 400:
|
|
249
|
+
return
|
|
250
|
+
detail = _error_detail(response)
|
|
251
|
+
if code in (401, 403):
|
|
252
|
+
raise LLMAuthError(f"凭据无效或无权访问:{detail}")
|
|
253
|
+
if code == 429:
|
|
254
|
+
raise LLMRateLimitError(f"触发限流:{detail}")
|
|
255
|
+
raise LLMUpstreamError(f"上游返回 {code}:{detail}")
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
async def _backoff(attempt: int, response: httpx.Response | None = None) -> None:
|
|
259
|
+
delay = BASE_BACKOFF_SECONDS * (2 ** (attempt - 1))
|
|
260
|
+
if response is not None:
|
|
261
|
+
retry_after = response.headers.get("Retry-After")
|
|
262
|
+
if retry_after and retry_after.isdigit():
|
|
263
|
+
delay = max(delay, float(retry_after))
|
|
264
|
+
await asyncio.sleep(min(delay + random.uniform(0, delay * 0.25), 30.0))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _error_detail(response: httpx.Response) -> str:
|
|
268
|
+
try:
|
|
269
|
+
payload = response.json()
|
|
270
|
+
except ValueError:
|
|
271
|
+
return response.text[:200]
|
|
272
|
+
if isinstance(payload, dict):
|
|
273
|
+
error = payload.get("error")
|
|
274
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
275
|
+
return error["message"][:300]
|
|
276
|
+
if isinstance(error, str):
|
|
277
|
+
return error[:300]
|
|
278
|
+
for key in ("message", "detail"):
|
|
279
|
+
if isinstance(payload.get(key), str):
|
|
280
|
+
return payload[key][:300]
|
|
281
|
+
return response.text[:200]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# 结构化输出解析
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
_FENCE_PATTERN = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def extract_json(text: str) -> dict[str, Any]:
|
|
292
|
+
"""从模型输出中稳健地取出 JSON 对象。
|
|
293
|
+
|
|
294
|
+
模型经常不顾"只输出 JSON"的指示,包上 ```json 围栏或加一段前言。
|
|
295
|
+
直接 json.loads 会失败,因此这里逐层退让:
|
|
296
|
+
原样解析 → 剥离代码围栏 → 截取首个 { 到末个 }。
|
|
297
|
+
|
|
298
|
+
全部失败时抛 LLMResponseError —— 上层据此重试或标记失败,
|
|
299
|
+
而不是把一段散文塞进结构化字段。
|
|
300
|
+
"""
|
|
301
|
+
candidate = (text or "").strip()
|
|
302
|
+
if not candidate:
|
|
303
|
+
raise LLMResponseError("模型返回了空内容")
|
|
304
|
+
|
|
305
|
+
for attempt in (candidate, _strip_fence(candidate), _slice_braces(candidate)):
|
|
306
|
+
if not attempt:
|
|
307
|
+
continue
|
|
308
|
+
try:
|
|
309
|
+
parsed = json.loads(attempt)
|
|
310
|
+
except ValueError:
|
|
311
|
+
continue
|
|
312
|
+
if isinstance(parsed, dict):
|
|
313
|
+
return parsed
|
|
314
|
+
raise LLMResponseError(f"期望 JSON 对象,实际为 {type(parsed).__name__}")
|
|
315
|
+
|
|
316
|
+
raise LLMResponseError(f"无法从模型输出中解析出 JSON:{candidate[:200]}")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _strip_fence(text: str) -> str:
|
|
320
|
+
match = _FENCE_PATTERN.search(text)
|
|
321
|
+
return match.group(1) if match else ""
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _slice_braces(text: str) -> str:
|
|
325
|
+
start, end = text.find("{"), text.rfind("}")
|
|
326
|
+
return text[start : end + 1] if start != -1 and end > start else ""
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""FastAPI 应用装配。"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
|
+
from fastapi.exceptions import RequestValidationError
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
12
|
+
|
|
13
|
+
from app.api.v1.router import router as v1_router
|
|
14
|
+
from app.core.config import Settings, get_settings
|
|
15
|
+
from app.core.db import dispose_engine, init_engine, session_scope
|
|
16
|
+
from app.core.exceptions import PlatformError
|
|
17
|
+
from app.core.logging import configure_logging
|
|
18
|
+
from app.core.permissions import Role
|
|
19
|
+
from app.middleware.audit import AuditMiddleware
|
|
20
|
+
from app.middleware.request_context import RequestContextMiddleware
|
|
21
|
+
from app.models.credential import Credential
|
|
22
|
+
|
|
23
|
+
logger = structlog.get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _bootstrap_admin(settings: Settings) -> None:
|
|
27
|
+
"""按环境变量创建首个管理员(仅当系统无用户时)。
|
|
28
|
+
|
|
29
|
+
这是容器化部署的便利手段 —— 无需手工打开浏览器完成初始化。
|
|
30
|
+
已初始化则静默跳过。
|
|
31
|
+
"""
|
|
32
|
+
if not settings.bootstrap_admin_password:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
from app.services import user_service
|
|
36
|
+
|
|
37
|
+
async with session_scope() as session:
|
|
38
|
+
if await user_service.count_users(session) > 0:
|
|
39
|
+
return
|
|
40
|
+
await user_service.create_user(
|
|
41
|
+
session,
|
|
42
|
+
username=settings.bootstrap_admin_username,
|
|
43
|
+
display_name=settings.bootstrap_admin_username,
|
|
44
|
+
email=settings.bootstrap_admin_email,
|
|
45
|
+
password=settings.bootstrap_admin_password,
|
|
46
|
+
role=Role.ADMIN,
|
|
47
|
+
)
|
|
48
|
+
logger.info("bootstrap_admin_created", username=settings.bootstrap_admin_username)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# 首次启动导入的凭据在界面上的名字,让人一眼看出它是哪来的
|
|
52
|
+
BOOTSTRAP_CREDENTIAL_NAME = "AtomGit(首次启动导入)"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _bootstrap_credential(
|
|
56
|
+
session: AsyncSession, settings: Settings, token: str
|
|
57
|
+
) -> Credential:
|
|
58
|
+
"""取或建引导用的 AtomGit 凭据。
|
|
59
|
+
|
|
60
|
+
已存在就复用:重启容器时 token 一般还在,不该因此再建一条重名凭据
|
|
61
|
+
(凭据名唯一,会撞约束)。
|
|
62
|
+
"""
|
|
63
|
+
from sqlalchemy import select
|
|
64
|
+
|
|
65
|
+
from app.models.credential import CredentialKind
|
|
66
|
+
from app.services import credential_service
|
|
67
|
+
|
|
68
|
+
credential = await session.scalar(
|
|
69
|
+
select(Credential).where(Credential.name == BOOTSTRAP_CREDENTIAL_NAME)
|
|
70
|
+
)
|
|
71
|
+
if credential is None:
|
|
72
|
+
credential = await credential_service.create_credential(
|
|
73
|
+
session,
|
|
74
|
+
settings,
|
|
75
|
+
name=BOOTSTRAP_CREDENTIAL_NAME,
|
|
76
|
+
kind=CredentialKind.ATOMGIT_TOKEN,
|
|
77
|
+
plaintext=token,
|
|
78
|
+
)
|
|
79
|
+
return credential
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _bootstrap_repository(settings: Settings) -> None:
|
|
83
|
+
"""首次启动纳管默认仓库。
|
|
84
|
+
|
|
85
|
+
使用者打开界面时不该是一片空白,所以**没配 token 也照样建这条记录** ——
|
|
86
|
+
此时同步不跑(同步层会跳过无凭据的仓库),界面上标明未配置凭据、
|
|
87
|
+
指引去补 token。
|
|
88
|
+
|
|
89
|
+
配了 token 就一并导入成凭据并关联上,数据随即开始拉取。令牌在库里是
|
|
90
|
+
AES-256-GCM 加密的,明文只存在于环境变量。
|
|
91
|
+
|
|
92
|
+
已纳管则静默跳过,所以重启容器不会重复建。
|
|
93
|
+
"""
|
|
94
|
+
target = settings.bootstrap_repository
|
|
95
|
+
if not target:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
owner, _, name = target.partition("/")
|
|
99
|
+
if not owner or not name:
|
|
100
|
+
logger.warning("bootstrap_repository_malformed", value=target)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
from app.models.repository import Repository
|
|
104
|
+
from app.services import repository_service
|
|
105
|
+
|
|
106
|
+
token = settings.atomgit_token
|
|
107
|
+
|
|
108
|
+
async with session_scope() as session:
|
|
109
|
+
existing = await repository_service.get_by_full_name(session, owner, name)
|
|
110
|
+
|
|
111
|
+
if existing is not None:
|
|
112
|
+
# 已纳管。但如果它是上次**没带 token**时建的,凭据还是空的 ——
|
|
113
|
+
# 这次配上了就补上去。否则"先启动、后给 token"这条路走不通:
|
|
114
|
+
# 使用者得自己去界面上再关联一次,而他已经把 token 给了命令行。
|
|
115
|
+
if existing.credential_id is None and token:
|
|
116
|
+
credential = await _bootstrap_credential(session, settings, token)
|
|
117
|
+
existing.credential_id = credential.id
|
|
118
|
+
logger.info("bootstrap_repository_credential_linked", repository=target)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
credential_id = None
|
|
122
|
+
if token:
|
|
123
|
+
credential_id = (await _bootstrap_credential(session, settings, token)).id
|
|
124
|
+
|
|
125
|
+
session.add(
|
|
126
|
+
Repository(
|
|
127
|
+
owner=owner,
|
|
128
|
+
name=name,
|
|
129
|
+
display_name=target,
|
|
130
|
+
credential_id=credential_id,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
logger.info(
|
|
134
|
+
"bootstrap_repository_created",
|
|
135
|
+
repository=target,
|
|
136
|
+
has_credential=credential_id is not None,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@asynccontextmanager
|
|
141
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
142
|
+
settings = get_settings()
|
|
143
|
+
configure_logging(level=settings.log_level, json_output=settings.is_production)
|
|
144
|
+
init_engine(settings)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
await _bootstrap_admin(settings)
|
|
148
|
+
except Exception:
|
|
149
|
+
logger.exception("bootstrap_admin_failed")
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
await _bootstrap_repository(settings)
|
|
153
|
+
except Exception:
|
|
154
|
+
# 引导失败不该让服务起不来:仓库可以之后在界面上手动纳管
|
|
155
|
+
logger.exception("bootstrap_repository_failed")
|
|
156
|
+
|
|
157
|
+
logger.info(
|
|
158
|
+
"application_started",
|
|
159
|
+
environment=settings.environment,
|
|
160
|
+
app_name=settings.app_name,
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
yield
|
|
164
|
+
finally:
|
|
165
|
+
await dispose_engine()
|
|
166
|
+
logger.info("application_stopped")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _problem_response(
|
|
170
|
+
status_code: int,
|
|
171
|
+
title: str,
|
|
172
|
+
detail: str,
|
|
173
|
+
instance: str | None,
|
|
174
|
+
code: str,
|
|
175
|
+
context: dict | None = None,
|
|
176
|
+
) -> JSONResponse:
|
|
177
|
+
return JSONResponse(
|
|
178
|
+
status_code=status_code,
|
|
179
|
+
content={
|
|
180
|
+
"type": f"https://kernel-sig.local/errors/{code}",
|
|
181
|
+
"title": title,
|
|
182
|
+
"status": status_code,
|
|
183
|
+
"detail": detail,
|
|
184
|
+
"instance": instance,
|
|
185
|
+
"code": code,
|
|
186
|
+
"context": context or None,
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def create_app() -> FastAPI:
|
|
192
|
+
settings = get_settings()
|
|
193
|
+
|
|
194
|
+
app = FastAPI(
|
|
195
|
+
title=settings.app_name,
|
|
196
|
+
version="0.1.0",
|
|
197
|
+
docs_url=None if settings.is_production else "/api/docs",
|
|
198
|
+
redoc_url=None,
|
|
199
|
+
openapi_url="/api/openapi.json",
|
|
200
|
+
lifespan=lifespan,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# 中间件注册顺序:后添加的先执行。
|
|
204
|
+
# 实际执行序:RequestContext → Audit → CORS → 路由
|
|
205
|
+
app.add_middleware(
|
|
206
|
+
CORSMiddleware,
|
|
207
|
+
allow_origins=["http://localhost:15173", "http://localhost:18080"],
|
|
208
|
+
allow_credentials=True,
|
|
209
|
+
allow_methods=["*"],
|
|
210
|
+
allow_headers=["*"],
|
|
211
|
+
)
|
|
212
|
+
app.add_middleware(AuditMiddleware)
|
|
213
|
+
app.add_middleware(RequestContextMiddleware)
|
|
214
|
+
|
|
215
|
+
@app.exception_handler(PlatformError)
|
|
216
|
+
async def _handle_platform_error(request: Request, exc: PlatformError) -> JSONResponse:
|
|
217
|
+
return _problem_response(
|
|
218
|
+
exc.status_code, exc.title, exc.detail, request.url.path, exc.code, exc.context
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
@app.exception_handler(RequestValidationError)
|
|
222
|
+
async def _handle_validation_error(
|
|
223
|
+
request: Request, exc: RequestValidationError
|
|
224
|
+
) -> JSONResponse:
|
|
225
|
+
errors = [
|
|
226
|
+
{"field": ".".join(str(p) for p in err["loc"][1:]), "message": err["msg"]}
|
|
227
|
+
for err in exc.errors()
|
|
228
|
+
]
|
|
229
|
+
return _problem_response(
|
|
230
|
+
422,
|
|
231
|
+
"Validation Failed",
|
|
232
|
+
"请求参数校验失败",
|
|
233
|
+
request.url.path,
|
|
234
|
+
"validation_error",
|
|
235
|
+
{"errors": errors},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
app.include_router(v1_router, prefix=settings.api_prefix)
|
|
239
|
+
return app
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
app = create_app()
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""自动审计中间件:记录所有写操作。
|
|
2
|
+
|
|
3
|
+
GET/HEAD/OPTIONS 不记录(读操作量大且审计价值低)。
|
|
4
|
+
敏感字段在写入前脱敏 —— 审计日志本身不能成为凭据泄露点。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.responses import Response
|
|
14
|
+
|
|
15
|
+
from app.core.db import get_session_factory
|
|
16
|
+
from app.models.audit import AuditLog, AuditResult
|
|
17
|
+
|
|
18
|
+
AUDITED_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
19
|
+
REDACTED_KEYS = {
|
|
20
|
+
"password",
|
|
21
|
+
"current_password",
|
|
22
|
+
"new_password",
|
|
23
|
+
"token",
|
|
24
|
+
"secret",
|
|
25
|
+
"api_key",
|
|
26
|
+
"private_token",
|
|
27
|
+
"refresh_token",
|
|
28
|
+
"access_token",
|
|
29
|
+
}
|
|
30
|
+
# 登录/刷新/初始化的载荷本身是凭据,整体不记录。
|
|
31
|
+
# 但登录**尝试**必须可归属,否则无法从审计日志发现暴力破解,
|
|
32
|
+
# 因此单独提取 username(绝非 password)作为行为归属。
|
|
33
|
+
CREDENTIAL_PATHS = {
|
|
34
|
+
"/api/v1/auth/login",
|
|
35
|
+
"/api/v1/auth/refresh",
|
|
36
|
+
"/api/v1/auth/setup",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _redact(value: Any) -> Any:
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
return {k: ("***" if k.lower() in REDACTED_KEYS else _redact(v)) for k, v in value.items()}
|
|
43
|
+
if isinstance(value, list):
|
|
44
|
+
return [_redact(item) for item in value]
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _username_from(body: Any) -> str | None:
|
|
49
|
+
if isinstance(body, dict):
|
|
50
|
+
username = body.get("username")
|
|
51
|
+
if isinstance(username, str) and username:
|
|
52
|
+
return username
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuditMiddleware(BaseHTTPMiddleware):
|
|
57
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
58
|
+
if request.method not in AUDITED_METHODS:
|
|
59
|
+
return await call_next(request)
|
|
60
|
+
|
|
61
|
+
raw_body = await self._read_json(request)
|
|
62
|
+
payload = self._extract_payload(raw_body, request.url.path)
|
|
63
|
+
claimed_actor = _username_from(raw_body) if request.url.path in CREDENTIAL_PATHS else None
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
response = await call_next(request)
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
await self._write_log(request, payload, AuditResult.FAILURE, claimed_actor, str(exc))
|
|
69
|
+
raise
|
|
70
|
+
|
|
71
|
+
result = AuditResult.SUCCESS if response.status_code < 400 else AuditResult.FAILURE
|
|
72
|
+
await self._write_log(request, payload, result, claimed_actor)
|
|
73
|
+
return response
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
async def _read_json(request: Request) -> Any:
|
|
77
|
+
try:
|
|
78
|
+
body = await request.body()
|
|
79
|
+
except Exception:
|
|
80
|
+
return None
|
|
81
|
+
if not body:
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(body)
|
|
85
|
+
except (ValueError, UnicodeDecodeError):
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _extract_payload(body: Any, path: str) -> dict[str, Any] | None:
|
|
90
|
+
if path in CREDENTIAL_PATHS:
|
|
91
|
+
username = _username_from(body)
|
|
92
|
+
# 记录尝试登录的账号名(非机密),不记录密码
|
|
93
|
+
return {"username": username, "_note": "凭据类请求,密码不记录"}
|
|
94
|
+
if body is None:
|
|
95
|
+
return None
|
|
96
|
+
return _redact(body) if isinstance(body, dict) else {"body": _redact(body)}
|
|
97
|
+
|
|
98
|
+
async def _write_log(
|
|
99
|
+
self,
|
|
100
|
+
request: Request,
|
|
101
|
+
payload: dict[str, Any] | None,
|
|
102
|
+
result: AuditResult,
|
|
103
|
+
claimed_actor: str | None = None,
|
|
104
|
+
error: str | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
# 只读取原始值:此处数据库会话已关闭,触碰 ORM 实例会触发
|
|
107
|
+
# DetachedInstanceError。认证依赖已把归属信息存为标量。
|
|
108
|
+
actor_user_id = getattr(request.state, "actor_user_id", None)
|
|
109
|
+
actor_username = getattr(request.state, "actor_username", None) or claimed_actor
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
factory = get_session_factory()
|
|
113
|
+
async with factory() as session:
|
|
114
|
+
session.add(
|
|
115
|
+
AuditLog(
|
|
116
|
+
actor_user_id=actor_user_id,
|
|
117
|
+
actor_username=actor_username,
|
|
118
|
+
actor_ip=request.client.host if request.client else None,
|
|
119
|
+
action=f"{request.method} {request.url.path}",
|
|
120
|
+
after=payload,
|
|
121
|
+
result=result,
|
|
122
|
+
error=error,
|
|
123
|
+
request_id=getattr(request.state, "request_id", None),
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
await session.commit()
|
|
127
|
+
except Exception:
|
|
128
|
+
# 审计写入失败绝不能影响主请求,但必须留痕便于排查
|
|
129
|
+
structlog.get_logger(__name__).exception("audit_log_write_failed")
|