@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,569 @@
|
|
|
1
|
+
"""AI 分析服务。
|
|
2
|
+
|
|
3
|
+
职责边界:
|
|
4
|
+
- 组装上下文(补丁正文、变更文件、提交信息)并套用领域 Prompt
|
|
5
|
+
- 按任务路由选模型,调用 LLM 网关,解析结构化结果
|
|
6
|
+
- 结果落库,并维护 ``input_hash`` 幂等
|
|
7
|
+
|
|
8
|
+
**成本约束**:AI 调用是这套系统里唯一按次计费的部分,因此
|
|
9
|
+
内容指纹未变时不重复调用;``force=True`` 才绕过该检查(人工重跑)。
|
|
10
|
+
|
|
11
|
+
**失败不改状态**:分析失败只记录 error 与耗时,不抛给调用方 ——
|
|
12
|
+
定时任务里一个 PR 的模型调用失败,不该让整轮分析中断。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import UTC, datetime, timedelta
|
|
20
|
+
|
|
21
|
+
import structlog
|
|
22
|
+
from sqlalchemy import func, select
|
|
23
|
+
from sqlalchemy.exc import IntegrityError
|
|
24
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
25
|
+
|
|
26
|
+
from app.core.config import Settings
|
|
27
|
+
from app.core.exceptions import NotFoundError, ValidationError
|
|
28
|
+
from app.integrations.llm import prompts
|
|
29
|
+
from app.integrations.llm.provider import (
|
|
30
|
+
ChatMessage,
|
|
31
|
+
CompletionRequest,
|
|
32
|
+
LLMError,
|
|
33
|
+
OpenAICompatibleProvider,
|
|
34
|
+
extract_json,
|
|
35
|
+
)
|
|
36
|
+
from app.models.ai import (
|
|
37
|
+
AIAnalysis,
|
|
38
|
+
AITask,
|
|
39
|
+
AnalysisStatus,
|
|
40
|
+
LLMProvider,
|
|
41
|
+
TaskRouting,
|
|
42
|
+
)
|
|
43
|
+
from app.models.classification import SubjectType
|
|
44
|
+
from app.models.issue import Issue
|
|
45
|
+
from app.models.pull_request import PRCommit, PRFile, PullRequest
|
|
46
|
+
from app.models.repository import Repository
|
|
47
|
+
from app.services import classification_service, credential_service, sync_service
|
|
48
|
+
|
|
49
|
+
logger = structlog.get_logger(__name__)
|
|
50
|
+
|
|
51
|
+
# 单次送入模型的补丁上限。内核补丁动辄上万行,全量送入既超上下文
|
|
52
|
+
# 也推高成本;截断处显式标注,避免模型误以为补丁就到这里。
|
|
53
|
+
MAX_DIFF_CHARS = 60_000
|
|
54
|
+
MAX_FILE_LIST = 200
|
|
55
|
+
|
|
56
|
+
TRUNCATION_NOTE = "\n\n[补丁已截断,仅展示前 {shown} 行]"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class AnalysisTarget:
|
|
61
|
+
"""一次分析的输入。input_hash 由此处的 variables 派生。"""
|
|
62
|
+
|
|
63
|
+
subject_type: SubjectType
|
|
64
|
+
subject_id: uuid.UUID
|
|
65
|
+
subject_number: int
|
|
66
|
+
subject_title: str
|
|
67
|
+
task: AITask
|
|
68
|
+
variables: dict[str, str] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
def input_hash(self) -> str:
|
|
71
|
+
# 与 sync_service.content_hash 用同一套指纹:任务类型与全部变量共同决定
|
|
72
|
+
return sync_service.content_hash(
|
|
73
|
+
self.task.value,
|
|
74
|
+
self.subject_title,
|
|
75
|
+
*[f"{key}={value}" for key, value in sorted(self.variables.items())],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# 模型选择
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def list_providers(session: AsyncSession) -> list[LLMProvider]:
|
|
85
|
+
stmt = select(LLMProvider).order_by(LLMProvider.is_default.desc(), LLMProvider.name)
|
|
86
|
+
return list((await session.scalars(stmt)).all())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def get_provider(session: AsyncSession, provider_id: uuid.UUID) -> LLMProvider | None:
|
|
90
|
+
return await session.get(LLMProvider, provider_id)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def require_provider(session: AsyncSession, provider_id: uuid.UUID) -> LLMProvider:
|
|
94
|
+
provider = await get_provider(session, provider_id)
|
|
95
|
+
if provider is None:
|
|
96
|
+
raise NotFoundError(
|
|
97
|
+
"模型提供方不存在", resource="llm_provider", identifier=str(provider_id)
|
|
98
|
+
)
|
|
99
|
+
return provider
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def list_routing(session: AsyncSession) -> list[TaskRouting]:
|
|
103
|
+
return list((await session.scalars(select(TaskRouting).order_by(TaskRouting.task))).all())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def resolve_provider(
|
|
107
|
+
session: AsyncSession, task: AITask
|
|
108
|
+
) -> tuple[LLMProvider, TaskRouting | None]:
|
|
109
|
+
"""按任务解析出该用哪个模型。
|
|
110
|
+
|
|
111
|
+
路由缺失或指向的 provider 已停用时回落到 is_default 的 provider ——
|
|
112
|
+
宁可退回默认模型可用,也好过因为一条配置缺失让整条链路停摆;
|
|
113
|
+
但两者都没有配置时必须明确报错,而不是静默使用某个随机端点。
|
|
114
|
+
"""
|
|
115
|
+
routing = await session.scalar(
|
|
116
|
+
select(TaskRouting).where(TaskRouting.task == task, TaskRouting.enabled.is_(True))
|
|
117
|
+
)
|
|
118
|
+
if routing is not None:
|
|
119
|
+
provider = await get_provider(session, routing.provider_id)
|
|
120
|
+
if provider is not None and provider.enabled:
|
|
121
|
+
return provider, routing
|
|
122
|
+
logger.warning(
|
|
123
|
+
"task_routing_provider_unavailable",
|
|
124
|
+
task=task.value,
|
|
125
|
+
provider_id=str(routing.provider_id),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
provider = await session.scalar(
|
|
129
|
+
select(LLMProvider)
|
|
130
|
+
.where(LLMProvider.enabled.is_(True), LLMProvider.is_default.is_(True))
|
|
131
|
+
.limit(1)
|
|
132
|
+
)
|
|
133
|
+
if provider is None:
|
|
134
|
+
provider = await session.scalar(
|
|
135
|
+
select(LLMProvider).where(LLMProvider.enabled.is_(True)).limit(1)
|
|
136
|
+
)
|
|
137
|
+
if provider is None:
|
|
138
|
+
raise ValidationError("尚未配置可用的模型提供方,请先在「设置 → AI」中添加")
|
|
139
|
+
return provider, routing
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_client(settings: Settings, provider: LLMProvider, credential) -> OpenAICompatibleProvider:
|
|
143
|
+
api_key = credential_service.reveal(settings, credential) if credential else ""
|
|
144
|
+
return OpenAICompatibleProvider(
|
|
145
|
+
name=provider.name,
|
|
146
|
+
base_url=provider.base_url,
|
|
147
|
+
api_key=api_key,
|
|
148
|
+
default_model=provider.model,
|
|
149
|
+
timeout=float(provider.timeout_seconds),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# 上下文组装
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _truncate(text: str | None, limit: int = MAX_DIFF_CHARS) -> str:
|
|
159
|
+
text = text or ""
|
|
160
|
+
if len(text) <= limit:
|
|
161
|
+
return text
|
|
162
|
+
shown_lines = text[:limit].count("\n")
|
|
163
|
+
return text[:limit] + TRUNCATION_NOTE.format(shown=shown_lines)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def _pull_context(session: AsyncSession, pull: PullRequest, task: AITask) -> dict[str, str]:
|
|
167
|
+
"""按任务组装 Prompt 变量。
|
|
168
|
+
|
|
169
|
+
不同任务需要的上下文差异很大:分类只看标题正文,风险审查需要完整补丁。
|
|
170
|
+
统一送全量上下文会让分类这种轻任务的成本与重任务持平。
|
|
171
|
+
"""
|
|
172
|
+
commits = list(
|
|
173
|
+
(
|
|
174
|
+
await session.scalars(
|
|
175
|
+
select(PRCommit)
|
|
176
|
+
.where(PRCommit.pull_request_id == pull.id)
|
|
177
|
+
.order_by(PRCommit.sequence)
|
|
178
|
+
)
|
|
179
|
+
).all()
|
|
180
|
+
)
|
|
181
|
+
files = list(
|
|
182
|
+
(
|
|
183
|
+
await session.scalars(
|
|
184
|
+
select(PRFile).where(PRFile.pull_request_id == pull.id).order_by(PRFile.filename)
|
|
185
|
+
)
|
|
186
|
+
).all()
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
file_list = "\n".join(
|
|
190
|
+
f"{item.filename} (+{item.additions}/-{item.deletions})"
|
|
191
|
+
+ (" [二进制]" if item.is_binary else "")
|
|
192
|
+
for item in files[:MAX_FILE_LIST]
|
|
193
|
+
)
|
|
194
|
+
commit_messages = "\n\n".join(
|
|
195
|
+
f"--- {commit.sha[:12]} ---\n{commit.message}" for commit in commits
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
variables = {
|
|
199
|
+
"title": pull.title,
|
|
200
|
+
"body": pull.body or "(无描述)",
|
|
201
|
+
"target_branch": pull.target_branch or "未知",
|
|
202
|
+
"author": pull.author_login or "未知",
|
|
203
|
+
"added_lines": str(pull.added_lines),
|
|
204
|
+
"removed_lines": str(pull.removed_lines),
|
|
205
|
+
"file_list": file_list or "(未同步变更文件)",
|
|
206
|
+
"commit_messages": commit_messages or "(未同步提交信息)",
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if task in (AITask.RISK_REVIEW, AITask.BACKPORT_VERIFY):
|
|
210
|
+
parts = [
|
|
211
|
+
f"### {item.filename}\n{item.diff or '(补齐丁内容)'}"
|
|
212
|
+
for item in files
|
|
213
|
+
if not item.is_binary
|
|
214
|
+
]
|
|
215
|
+
variables["diff"] = _truncate("\n\n".join(parts)) or "(未同步补丁内容)"
|
|
216
|
+
variables["backport_patch"] = variables["diff"]
|
|
217
|
+
variables["upstream_patch"] = _upstream_patch_hint(pull)
|
|
218
|
+
|
|
219
|
+
return variables
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _upstream_patch_hint(pull: PullRequest) -> str:
|
|
223
|
+
"""上游补丁原文不在本平台的数据范围内。
|
|
224
|
+
|
|
225
|
+
与其让模型凭空"想象"上游补丁再输出一份看似专业的对比结论,
|
|
226
|
+
不如如实告知缺失 —— 后者的结论至少是可判断的。
|
|
227
|
+
"""
|
|
228
|
+
return (
|
|
229
|
+
"(未提供上游补丁原文。若无法核对,请在 summary 中说明并令 equivalent 为 null,"
|
|
230
|
+
"不要推测上游内容。)"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def build_target(
|
|
235
|
+
session: AsyncSession,
|
|
236
|
+
*,
|
|
237
|
+
task: AITask,
|
|
238
|
+
pull: PullRequest | None = None,
|
|
239
|
+
issue: Issue | None = None,
|
|
240
|
+
) -> AnalysisTarget:
|
|
241
|
+
if pull is not None:
|
|
242
|
+
return AnalysisTarget(
|
|
243
|
+
subject_type=SubjectType.PULL_REQUEST,
|
|
244
|
+
subject_id=pull.id,
|
|
245
|
+
subject_number=pull.number,
|
|
246
|
+
subject_title=pull.title,
|
|
247
|
+
task=task,
|
|
248
|
+
variables=await _pull_context(session, pull, task),
|
|
249
|
+
)
|
|
250
|
+
if issue is not None:
|
|
251
|
+
return AnalysisTarget(
|
|
252
|
+
subject_type=SubjectType.ISSUE,
|
|
253
|
+
subject_id=issue.id,
|
|
254
|
+
subject_number=issue.number,
|
|
255
|
+
subject_title=issue.title,
|
|
256
|
+
task=task,
|
|
257
|
+
variables={
|
|
258
|
+
"title": issue.title,
|
|
259
|
+
"body": issue.body or "(无描述)",
|
|
260
|
+
"file_list": "(Issue 无变更文件)",
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
raise ValidationError("必须提供 PR 或 Issue 之一")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# 执行
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def get_analysis(
|
|
272
|
+
session: AsyncSession,
|
|
273
|
+
subject_type: SubjectType,
|
|
274
|
+
subject_id: uuid.UUID,
|
|
275
|
+
task: AITask,
|
|
276
|
+
) -> AIAnalysis | None:
|
|
277
|
+
stmt = select(AIAnalysis).where(
|
|
278
|
+
AIAnalysis.subject_type == subject_type.value,
|
|
279
|
+
AIAnalysis.subject_id == subject_id,
|
|
280
|
+
AIAnalysis.task == task,
|
|
281
|
+
)
|
|
282
|
+
return await session.scalar(stmt)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def list_analyses(
|
|
286
|
+
session: AsyncSession,
|
|
287
|
+
subject_type: SubjectType,
|
|
288
|
+
subject_id: uuid.UUID,
|
|
289
|
+
) -> list[AIAnalysis]:
|
|
290
|
+
stmt = (
|
|
291
|
+
select(AIAnalysis)
|
|
292
|
+
.where(
|
|
293
|
+
AIAnalysis.subject_type == subject_type.value,
|
|
294
|
+
AIAnalysis.subject_id == subject_id,
|
|
295
|
+
)
|
|
296
|
+
.order_by(AIAnalysis.created_at.desc())
|
|
297
|
+
)
|
|
298
|
+
return list((await session.scalars(stmt)).all())
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def test_provider(session: AsyncSession, settings: Settings, provider: LLMProvider) -> dict:
|
|
302
|
+
"""用一个最小请求验证端点、模型名与凭据是否真的可用。
|
|
303
|
+
|
|
304
|
+
只发一句 "ping",不消耗有价值的 token,也不走 JSON 模式 ——
|
|
305
|
+
很多供应商的轻量模型不支持 response_format,用它测会把
|
|
306
|
+
"配置正确但选了不支持的模型"误报成连接失败。
|
|
307
|
+
"""
|
|
308
|
+
credential = (
|
|
309
|
+
await credential_service.require_credential(session, provider.credential_id)
|
|
310
|
+
if provider.credential_id
|
|
311
|
+
else None
|
|
312
|
+
)
|
|
313
|
+
started = datetime.now(UTC)
|
|
314
|
+
try:
|
|
315
|
+
async with build_client(settings, provider, credential) as client:
|
|
316
|
+
response = await client.complete(
|
|
317
|
+
CompletionRequest(
|
|
318
|
+
messages=[ChatMessage(role="user", content="ping")],
|
|
319
|
+
model=provider.model,
|
|
320
|
+
temperature=0.0,
|
|
321
|
+
max_tokens=8,
|
|
322
|
+
timeout=min(float(provider.timeout_seconds), 60.0),
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
except LLMError as exc:
|
|
326
|
+
provider.last_error = str(exc)[:500]
|
|
327
|
+
await session.flush()
|
|
328
|
+
return {"ok": False, "detail": str(exc), "latency_ms": None, "model": None}
|
|
329
|
+
|
|
330
|
+
latency_ms = int((datetime.now(UTC) - started).total_seconds() * 1000)
|
|
331
|
+
provider.last_used_at = datetime.now(UTC)
|
|
332
|
+
provider.last_error = None
|
|
333
|
+
await session.flush()
|
|
334
|
+
return {
|
|
335
|
+
"ok": True,
|
|
336
|
+
"detail": "端点可用",
|
|
337
|
+
"latency_ms": latency_ms,
|
|
338
|
+
"model": response.model,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def execute(
|
|
343
|
+
session: AsyncSession,
|
|
344
|
+
settings: Settings,
|
|
345
|
+
repository: Repository,
|
|
346
|
+
target: AnalysisTarget,
|
|
347
|
+
*,
|
|
348
|
+
triggered_by: uuid.UUID | None = None,
|
|
349
|
+
force: bool = False,
|
|
350
|
+
) -> AIAnalysis:
|
|
351
|
+
"""执行一次分析。幂等:输入未变且此前成功过则直接返回既有结果。"""
|
|
352
|
+
fingerprint = target.input_hash()
|
|
353
|
+
existing = await get_analysis(session, target.subject_type, target.subject_id, target.task)
|
|
354
|
+
|
|
355
|
+
if (
|
|
356
|
+
existing is not None
|
|
357
|
+
and not force
|
|
358
|
+
and existing.status is AnalysisStatus.SUCCEEDED
|
|
359
|
+
and existing.input_hash == fingerprint
|
|
360
|
+
):
|
|
361
|
+
return existing
|
|
362
|
+
|
|
363
|
+
# 配置缺失属于部署问题,不是分析失败:在写任何状态之前就抛出去。
|
|
364
|
+
#
|
|
365
|
+
# 若把它当作分析失败落库,未配置模型时每轮定时任务都会写入几条 FAILED ——
|
|
366
|
+
# 用量面板上看起来像模型出了故障,实际只是还没配;而且这些空记录
|
|
367
|
+
# 会一直累积下去。这里提前解析,让调用方能拿到"尚未配置"这个明确信号。
|
|
368
|
+
provider, routing = await resolve_provider(session, target.task)
|
|
369
|
+
credential = (
|
|
370
|
+
await credential_service.require_credential(session, provider.credential_id)
|
|
371
|
+
if provider.credential_id
|
|
372
|
+
else None
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if existing is None:
|
|
376
|
+
fresh = AIAnalysis(
|
|
377
|
+
repository_id=repository.id,
|
|
378
|
+
subject_type=target.subject_type.value,
|
|
379
|
+
subject_id=target.subject_id,
|
|
380
|
+
task=target.task,
|
|
381
|
+
)
|
|
382
|
+
try:
|
|
383
|
+
# 唯一约束 (subject_type, subject_id, task) 规定一个对象只有一行,
|
|
384
|
+
# 但"先查再插"挡不住并发:定时任务和用户点「重新分析」可能同时
|
|
385
|
+
# 选中同一个对象,两边的 SELECT 都看不见对方还没提交的行,于是
|
|
386
|
+
# 都走到这里插入。后到的那次会被唯一约束拦下 —— 接住它,改用
|
|
387
|
+
# 对方那条记录继续写即可。不接的话,IntegrityError 会一路冒到
|
|
388
|
+
# 接口层变成 500,而它本来只是"别人已经在分析了"。
|
|
389
|
+
#
|
|
390
|
+
# add 必须写在 SAVEPOINT 之内:写在外面时 flush 失败会把**整个
|
|
391
|
+
# 会话**标记成待回滚(PendingRollbackError),而 worker 一个会话
|
|
392
|
+
# 要连着分析几十个对象,作废一次等于整批白跑。两种写法都实测过。
|
|
393
|
+
async with session.begin_nested():
|
|
394
|
+
session.add(fresh)
|
|
395
|
+
await session.flush()
|
|
396
|
+
existing = fresh
|
|
397
|
+
except IntegrityError:
|
|
398
|
+
existing = await get_analysis(
|
|
399
|
+
session, target.subject_type, target.subject_id, target.task
|
|
400
|
+
)
|
|
401
|
+
if existing is None:
|
|
402
|
+
raise
|
|
403
|
+
|
|
404
|
+
existing.status = AnalysisStatus.RUNNING
|
|
405
|
+
existing.input_hash = fingerprint
|
|
406
|
+
existing.started_at = datetime.now(UTC)
|
|
407
|
+
existing.finished_at = None
|
|
408
|
+
existing.error = None
|
|
409
|
+
existing.triggered_by = triggered_by
|
|
410
|
+
await session.flush()
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
system_prompt, user_template = _template_for(target.task, routing)
|
|
414
|
+
messages = [
|
|
415
|
+
ChatMessage(role="system", content=system_prompt),
|
|
416
|
+
ChatMessage(role="user", content=_render(user_template, target.variables)),
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
async with build_client(settings, provider, credential) as client:
|
|
420
|
+
response = await client.complete(
|
|
421
|
+
CompletionRequest(
|
|
422
|
+
messages=messages,
|
|
423
|
+
model=provider.model,
|
|
424
|
+
temperature=provider.temperature,
|
|
425
|
+
max_tokens=provider.max_tokens,
|
|
426
|
+
json_mode=target.task is not AITask.RISK_REVIEW,
|
|
427
|
+
timeout=float(provider.timeout_seconds),
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
existing.result = extract_json(response.content)
|
|
432
|
+
existing.raw_response = response.content
|
|
433
|
+
existing.model = response.model
|
|
434
|
+
existing.provider_name = provider.name
|
|
435
|
+
existing.prompt_tokens = response.prompt_tokens
|
|
436
|
+
existing.completion_tokens = response.completion_tokens
|
|
437
|
+
existing.status = AnalysisStatus.SUCCEEDED
|
|
438
|
+
|
|
439
|
+
provider.last_used_at = datetime.now(UTC)
|
|
440
|
+
provider.last_error = None
|
|
441
|
+
except LLMError as exc:
|
|
442
|
+
# 上游/模型侧失败只落状态,不往上抛:定时任务里单条失败不该中断整轮
|
|
443
|
+
existing.status = AnalysisStatus.FAILED
|
|
444
|
+
existing.error = str(exc)[:2000]
|
|
445
|
+
logger.warning(
|
|
446
|
+
"ai_analysis_failed",
|
|
447
|
+
task=target.task.value,
|
|
448
|
+
subject=target.subject_number,
|
|
449
|
+
error=str(exc),
|
|
450
|
+
)
|
|
451
|
+
provider.last_error = str(exc)[:500]
|
|
452
|
+
finally:
|
|
453
|
+
existing.finished_at = datetime.now(UTC)
|
|
454
|
+
await session.flush()
|
|
455
|
+
|
|
456
|
+
return existing
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _template_for(task: AITask, routing: TaskRouting | None) -> tuple[str, str]:
|
|
460
|
+
"""取 Prompt 模板。路由上配置的 system_prompt 覆盖内置版本。"""
|
|
461
|
+
system_prompt, user_template = prompts.get_template(task.value)
|
|
462
|
+
if routing is not None and routing.system_prompt:
|
|
463
|
+
system_prompt = routing.system_prompt
|
|
464
|
+
return system_prompt, user_template
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _render(template: str, variables: dict[str, str]) -> str:
|
|
468
|
+
"""填充模板占位符。
|
|
469
|
+
|
|
470
|
+
用 ``format_map`` 配合缺失键兜底类,而不是 ``str.format``:
|
|
471
|
+
模型的输出模板由用户可编辑,某个占位符写错不该让整次分析崩掉。
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
class _Safe(dict):
|
|
475
|
+
def __missing__(self, key: str) -> str:
|
|
476
|
+
return f"(缺少 {key})"
|
|
477
|
+
|
|
478
|
+
return template.format_map(_Safe(variables))
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ---------------------------------------------------------------------------
|
|
482
|
+
# 与分类子系统的衔接
|
|
483
|
+
# ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
async def classify_subject(
|
|
487
|
+
session: AsyncSession,
|
|
488
|
+
settings: Settings,
|
|
489
|
+
repository: Repository,
|
|
490
|
+
*,
|
|
491
|
+
pull: PullRequest | None = None,
|
|
492
|
+
issue: Issue | None = None,
|
|
493
|
+
triggered_by: uuid.UUID | None = None,
|
|
494
|
+
force: bool = False,
|
|
495
|
+
) -> AIAnalysis:
|
|
496
|
+
"""用模型补判规则未命中的分类,并把结果写回分类表。"""
|
|
497
|
+
target = await build_target(session, task=AITask.CLASSIFY, pull=pull, issue=issue)
|
|
498
|
+
analysis = await execute(
|
|
499
|
+
session, settings, repository, target, triggered_by=triggered_by, force=force
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
if analysis.status is AnalysisStatus.SUCCEEDED and analysis.result:
|
|
503
|
+
result = analysis.result
|
|
504
|
+
await classification_service.apply_ai_result(
|
|
505
|
+
session,
|
|
506
|
+
repository_id=repository.id,
|
|
507
|
+
subject_type=target.subject_type,
|
|
508
|
+
subject_id=target.subject_id,
|
|
509
|
+
kind=str(result.get("kind", "unknown")),
|
|
510
|
+
subsystem=result.get("subsystem"),
|
|
511
|
+
confidence=float(result.get("confidence") or 0.0),
|
|
512
|
+
reason=result.get("reason"),
|
|
513
|
+
)
|
|
514
|
+
return analysis
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# ---------------------------------------------------------------------------
|
|
518
|
+
# 用量
|
|
519
|
+
# ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
async def usage_stats(
|
|
523
|
+
session: AsyncSession,
|
|
524
|
+
*,
|
|
525
|
+
repository_id: uuid.UUID | None = None,
|
|
526
|
+
since_days: int = 30,
|
|
527
|
+
) -> dict:
|
|
528
|
+
"""统计模型用量。
|
|
529
|
+
|
|
530
|
+
AI 调用是这套系统里唯一按次计费的开销,用量必须能被直接看到 ——
|
|
531
|
+
否则"这个月为什么花了这么多"永远查不清。
|
|
532
|
+
"""
|
|
533
|
+
cutoff = datetime.now(UTC) - timedelta(days=since_days)
|
|
534
|
+
conditions = [AIAnalysis.created_at >= cutoff]
|
|
535
|
+
if repository_id is not None:
|
|
536
|
+
conditions.append(AIAnalysis.repository_id == repository_id)
|
|
537
|
+
|
|
538
|
+
stmt = (
|
|
539
|
+
select(
|
|
540
|
+
AIAnalysis.task,
|
|
541
|
+
AIAnalysis.status,
|
|
542
|
+
func.count(),
|
|
543
|
+
func.coalesce(func.sum(AIAnalysis.prompt_tokens), 0),
|
|
544
|
+
func.coalesce(func.sum(AIAnalysis.completion_tokens), 0),
|
|
545
|
+
)
|
|
546
|
+
.where(*conditions)
|
|
547
|
+
.group_by(AIAnalysis.task, AIAnalysis.status)
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
analyses = succeeded = failed = prompt_tokens = completion_tokens = 0
|
|
551
|
+
by_task: dict[str, int] = {}
|
|
552
|
+
for task, status, count, prompt, completion in (await session.execute(stmt)).all():
|
|
553
|
+
analyses += count
|
|
554
|
+
prompt_tokens += prompt
|
|
555
|
+
completion_tokens += completion
|
|
556
|
+
by_task[task.value] = by_task.get(task.value, 0) + count
|
|
557
|
+
if status is AnalysisStatus.SUCCEEDED:
|
|
558
|
+
succeeded += count
|
|
559
|
+
elif status is AnalysisStatus.FAILED:
|
|
560
|
+
failed += count
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
"analyses": analyses,
|
|
564
|
+
"succeeded": succeeded,
|
|
565
|
+
"failed": failed,
|
|
566
|
+
"prompt_tokens": prompt_tokens,
|
|
567
|
+
"completion_tokens": completion_tokens,
|
|
568
|
+
"by_task": by_task,
|
|
569
|
+
}
|