@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.
Files changed (198) hide show
  1. package/.env.example +50 -0
  2. package/LICENSE +127 -0
  3. package/Makefile +66 -0
  4. package/README.md +279 -0
  5. package/backend/.dockerignore +12 -0
  6. package/backend/Dockerfile +43 -0
  7. package/backend/alembic/env.py +75 -0
  8. package/backend/alembic/script.py.mako +25 -0
  9. package/backend/alembic/versions/0001_initial.py +88 -0
  10. package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
  11. package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
  12. package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
  13. package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
  14. package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
  15. package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
  16. package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
  17. package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
  18. package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
  19. package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
  20. package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
  21. package/backend/alembic.ini +40 -0
  22. package/backend/app/__init__.py +0 -0
  23. package/backend/app/api/__init__.py +0 -0
  24. package/backend/app/api/deps.py +74 -0
  25. package/backend/app/api/v1/__init__.py +0 -0
  26. package/backend/app/api/v1/ai.py +386 -0
  27. package/backend/app/api/v1/analytics.py +48 -0
  28. package/backend/app/api/v1/attention.py +142 -0
  29. package/backend/app/api/v1/auth.py +106 -0
  30. package/backend/app/api/v1/classification.py +192 -0
  31. package/backend/app/api/v1/health.py +29 -0
  32. package/backend/app/api/v1/issues.py +143 -0
  33. package/backend/app/api/v1/pulls.py +388 -0
  34. package/backend/app/api/v1/repositories.py +273 -0
  35. package/backend/app/api/v1/router.py +32 -0
  36. package/backend/app/api/v1/sig.py +565 -0
  37. package/backend/app/api/v1/users.py +87 -0
  38. package/backend/app/api/v1/webhooks.py +135 -0
  39. package/backend/app/core/__init__.py +0 -0
  40. package/backend/app/core/config.py +72 -0
  41. package/backend/app/core/crypto.py +74 -0
  42. package/backend/app/core/db.py +79 -0
  43. package/backend/app/core/exceptions.py +60 -0
  44. package/backend/app/core/logging.py +69 -0
  45. package/backend/app/core/permissions.py +87 -0
  46. package/backend/app/core/queue.py +57 -0
  47. package/backend/app/core/security.py +69 -0
  48. package/backend/app/domain/__init__.py +0 -0
  49. package/backend/app/domain/attention.py +618 -0
  50. package/backend/app/domain/classification.py +689 -0
  51. package/backend/app/domain/meeting.py +408 -0
  52. package/backend/app/domain/release.py +163 -0
  53. package/backend/app/domain/review.py +416 -0
  54. package/backend/app/domain/sig.py +178 -0
  55. package/backend/app/domain/sig_info.py +201 -0
  56. package/backend/app/integrations/__init__.py +0 -0
  57. package/backend/app/integrations/atomgit/__init__.py +0 -0
  58. package/backend/app/integrations/atomgit/client.py +505 -0
  59. package/backend/app/integrations/atomgit/models.py +311 -0
  60. package/backend/app/integrations/llm/__init__.py +0 -0
  61. package/backend/app/integrations/llm/prompts.py +222 -0
  62. package/backend/app/integrations/llm/provider.py +326 -0
  63. package/backend/app/main.py +242 -0
  64. package/backend/app/middleware/__init__.py +0 -0
  65. package/backend/app/middleware/audit.py +129 -0
  66. package/backend/app/middleware/request_context.py +42 -0
  67. package/backend/app/models/__init__.py +112 -0
  68. package/backend/app/models/ai.py +214 -0
  69. package/backend/app/models/attention.py +131 -0
  70. package/backend/app/models/attention_settings.py +61 -0
  71. package/backend/app/models/audit.py +53 -0
  72. package/backend/app/models/base.py +35 -0
  73. package/backend/app/models/classification.py +144 -0
  74. package/backend/app/models/credential.py +56 -0
  75. package/backend/app/models/issue.py +132 -0
  76. package/backend/app/models/meeting.py +189 -0
  77. package/backend/app/models/pull_request.py +288 -0
  78. package/backend/app/models/repository.py +196 -0
  79. package/backend/app/models/sig.py +194 -0
  80. package/backend/app/models/user.py +44 -0
  81. package/backend/app/schemas/__init__.py +0 -0
  82. package/backend/app/schemas/ai.py +120 -0
  83. package/backend/app/schemas/attention.py +43 -0
  84. package/backend/app/schemas/auth.py +26 -0
  85. package/backend/app/schemas/classification.py +58 -0
  86. package/backend/app/schemas/common.py +43 -0
  87. package/backend/app/schemas/pull_request.py +214 -0
  88. package/backend/app/schemas/repository.py +94 -0
  89. package/backend/app/schemas/sig.py +246 -0
  90. package/backend/app/schemas/user.py +79 -0
  91. package/backend/app/services/__init__.py +0 -0
  92. package/backend/app/services/ai_service.py +569 -0
  93. package/backend/app/services/analytics_service.py +390 -0
  94. package/backend/app/services/attention_queue.py +451 -0
  95. package/backend/app/services/attention_service.py +432 -0
  96. package/backend/app/services/auth_service.py +74 -0
  97. package/backend/app/services/classification_service.py +658 -0
  98. package/backend/app/services/credential_service.py +105 -0
  99. package/backend/app/services/pull_query.py +273 -0
  100. package/backend/app/services/release_service.py +446 -0
  101. package/backend/app/services/repository_service.py +132 -0
  102. package/backend/app/services/sig_service.py +385 -0
  103. package/backend/app/services/sync_service.py +752 -0
  104. package/backend/app/services/user_service.py +68 -0
  105. package/backend/app/worker.py +389 -0
  106. package/backend/entrypoint.sh +10 -0
  107. package/backend/pyproject.toml +68 -0
  108. package/backend/tests/test_analysis_api.py +154 -0
  109. package/backend/tests/test_atomgit_client.py +360 -0
  110. package/backend/tests/test_atomgit_models.py +257 -0
  111. package/backend/tests/test_attention.py +291 -0
  112. package/backend/tests/test_audit_middleware.py +135 -0
  113. package/backend/tests/test_classification.py +498 -0
  114. package/backend/tests/test_config.py +41 -0
  115. package/backend/tests/test_crypto.py +68 -0
  116. package/backend/tests/test_exceptions.py +62 -0
  117. package/backend/tests/test_health.py +63 -0
  118. package/backend/tests/test_llm_provider.py +320 -0
  119. package/backend/tests/test_meeting_domain.py +169 -0
  120. package/backend/tests/test_permissions.py +69 -0
  121. package/backend/tests/test_pull_query_wiring.py +66 -0
  122. package/backend/tests/test_pull_schemas.py +82 -0
  123. package/backend/tests/test_release_domain.py +82 -0
  124. package/backend/tests/test_review_parser.py +301 -0
  125. package/backend/tests/test_schemas_user.py +97 -0
  126. package/backend/tests/test_security.py +92 -0
  127. package/cli/index.js +338 -0
  128. package/compose.yaml +105 -0
  129. package/frontend/.dockerignore +4 -0
  130. package/frontend/Dockerfile +27 -0
  131. package/frontend/index.html +14 -0
  132. package/frontend/nginx.conf +47 -0
  133. package/frontend/package-lock.json +5020 -0
  134. package/frontend/package.json +35 -0
  135. package/frontend/src/api/ai.ts +124 -0
  136. package/frontend/src/api/analytics.ts +66 -0
  137. package/frontend/src/api/attention.ts +181 -0
  138. package/frontend/src/api/auth.ts +74 -0
  139. package/frontend/src/api/classification.ts +170 -0
  140. package/frontend/src/api/issues.ts +90 -0
  141. package/frontend/src/api/pulls.ts +233 -0
  142. package/frontend/src/api/repositories.ts +95 -0
  143. package/frontend/src/api/sig.ts +232 -0
  144. package/frontend/src/app/antd-theme.ts +94 -0
  145. package/frontend/src/app/providers.tsx +59 -0
  146. package/frontend/src/app/router.tsx +411 -0
  147. package/frontend/src/app/search.ts +30 -0
  148. package/frontend/src/components/ClassificationBadge.tsx +58 -0
  149. package/frontend/src/components/GateBadge.tsx +13 -0
  150. package/frontend/src/components/SeverityBadge.tsx +20 -0
  151. package/frontend/src/components/layout/AppShell.tsx +16 -0
  152. package/frontend/src/components/layout/AuthLayout.tsx +32 -0
  153. package/frontend/src/components/layout/Sidebar.tsx +223 -0
  154. package/frontend/src/components/layout/TopBar.tsx +47 -0
  155. package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
  156. package/frontend/src/components/pulls/FacetRail.tsx +199 -0
  157. package/frontend/src/components/pulls/LabelChips.tsx +87 -0
  158. package/frontend/src/components/ui/alert.tsx +30 -0
  159. package/frontend/src/components/ui/badge.tsx +53 -0
  160. package/frontend/src/components/ui/button.tsx +51 -0
  161. package/frontend/src/components/ui/card.tsx +64 -0
  162. package/frontend/src/components/ui/chart-theme.ts +65 -0
  163. package/frontend/src/components/ui/data-table.tsx +39 -0
  164. package/frontend/src/components/ui/echart.tsx +70 -0
  165. package/frontend/src/components/ui/empty-state.tsx +22 -0
  166. package/frontend/src/components/ui/input.tsx +39 -0
  167. package/frontend/src/components/ui/lazy-chart.tsx +21 -0
  168. package/frontend/src/components/ui/skeleton.tsx +19 -0
  169. package/frontend/src/hooks/use-current-repository.ts +44 -0
  170. package/frontend/src/hooks/use-current-user.ts +38 -0
  171. package/frontend/src/lib/api-client.ts +93 -0
  172. package/frontend/src/lib/css-color.ts +60 -0
  173. package/frontend/src/lib/utils.ts +45 -0
  174. package/frontend/src/main.tsx +22 -0
  175. package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
  176. package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
  177. package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
  178. package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
  179. package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
  180. package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
  181. package/frontend/src/pages/login/LoginPage.tsx +95 -0
  182. package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
  183. package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
  184. package/frontend/src/pages/members/MembersPage.tsx +534 -0
  185. package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
  186. package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
  187. package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
  188. package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
  189. package/frontend/src/pages/setup/SetupPage.tsx +144 -0
  190. package/frontend/src/styles/globals.css +147 -0
  191. package/frontend/tsconfig.json +22 -0
  192. package/frontend/vite.config.ts +57 -0
  193. package/package.json +48 -0
  194. package/scripts/e2e-auth-flow.py +131 -0
  195. package/scripts/e2e-pr-detail.py +128 -0
  196. package/scripts/e2e-verify.py +773 -0
  197. package/scripts/verify-ai-pipeline.py +616 -0
  198. package/scripts/verify-analysis-pipeline.py +303 -0
@@ -0,0 +1,616 @@
1
+ #!/usr/bin/env python
2
+ """AI 分析链路的端到端校验。
3
+
4
+ 用一个本地桩服务冒充 OpenAI 兼容端点,验证真实代码路径:
5
+ 路由选模型 → 解密凭据 → 发请求 → 解析 JSON → 落库 → 幂等。
6
+
7
+ 没有桩服务就只能测到 mock 层,而这一链路上最容易出错的地方
8
+ (请求头、payload 结构、响应解析、状态机)恰恰都在真实 HTTP 之后。
9
+
10
+ 用法(在 backend 目录下):
11
+ KSC_SECRET_KEY=... python ../scripts/verify-ai-pipeline.py
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import sys
19
+ import threading
20
+ import uuid
21
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
+
23
+ from sqlalchemy import delete, func, inspect as sa_inspect, select
24
+
25
+ from app.core.config import get_settings
26
+ from app.core.db import dispose_engine, init_engine, session_scope
27
+ from app.core.exceptions import ValidationError
28
+ from app.models.ai import AIAnalysis, AITask, AnalysisStatus, LLMProvider
29
+ from app.models.classification import Classification, ClassificationSource, PRKind, SubjectType
30
+ from app.models.pull_request import PullRequest
31
+ from app.models.credential import Credential, CredentialKind
32
+ from app.services import ai_service, classification_service, credential_service, repository_service
33
+
34
+ STUB_PORT = 18099
35
+ STUB_URL = f"http://127.0.0.1:{STUB_PORT}/v1"
36
+ PROVIDER_NAME = "verify-stub"
37
+
38
+ failures: list[str] = []
39
+
40
+
41
+ def check(condition: bool, message: str) -> None:
42
+ print(f" {'ok ' if condition else 'FAIL'} {message}")
43
+ if not condition:
44
+ failures.append(message)
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # 桩服务
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class _Stub:
53
+ """记录收到的请求,并按模式返回响应。
54
+
55
+ 模式:ok(正常 JSON)/ garbage(散文包着 JSON)/ auth_error(401)
56
+ """
57
+
58
+ def __init__(self) -> None:
59
+ self.requests: list[dict] = []
60
+ self.mode = "ok"
61
+
62
+ def payload(self) -> dict:
63
+ return {
64
+ "model": "stub-model",
65
+ "choices": [
66
+ {
67
+ "message": {
68
+ "content": json.dumps(
69
+ {
70
+ "kind": "bugfix",
71
+ "subsystem": "net",
72
+ "confidence": 0.82,
73
+ "reason": "标题描述了并发相关的缺陷修复",
74
+ },
75
+ ensure_ascii=False,
76
+ )
77
+ },
78
+ "finish_reason": "stop",
79
+ }
80
+ ],
81
+ "usage": {"prompt_tokens": 120, "completion_tokens": 30},
82
+ }
83
+
84
+
85
+ STUB = _Stub()
86
+
87
+
88
+ class _Handler(BaseHTTPRequestHandler):
89
+ def do_POST(self) -> None:
90
+ length = int(self.headers.get("Content-Length", 0))
91
+ body = json.loads(self.rfile.read(length) or b"{}")
92
+ STUB.requests.append({"path": self.path, "body": body, "headers": dict(self.headers)})
93
+
94
+ if STUB.mode == "auth_error":
95
+ self._send(401, {"error": {"message": "invalid api key"}})
96
+ return
97
+ if STUB.mode == "bad_kind":
98
+ # 模型偶尔会自造一个不在枚举里的取值 —— 必须被显式拒绝
99
+ content = '{"kind": "perf_optimization", "subsystem": "net", "confidence": 0.9}'
100
+ self._send(200, {"choices": [{"message": {"content": content}}], "model": "stub-model"})
101
+ return
102
+ if STUB.mode == "garbage":
103
+ # 模型不顾"只输出 JSON"的指示,包了围栏又加了前言:
104
+ # 这是常态而非异常,必须能被 extract_json 兜住
105
+ content = '好的,我的判断如下:\n```json\n{"kind": "feature", "subsystem": "mm", '
106
+ content += '"confidence": 0.7, "reason": "新增了内存管理接口"}\n```\n希望有帮助。'
107
+ self._send(200, {"choices": [{"message": {"content": content}}], "model": "stub-model"})
108
+ return
109
+ self._send(200, STUB.payload())
110
+
111
+ def _send(self, status: int, payload: dict) -> None:
112
+ data = json.dumps(payload).encode("utf-8")
113
+ self.send_response(status)
114
+ self.send_header("Content-Type", "application/json")
115
+ self.send_header("Content-Length", str(len(data)))
116
+ self.end_headers()
117
+ self.wfile.write(data)
118
+
119
+ def log_message(self, *args: object) -> None:
120
+ return # 静默,避免污染校验输出
121
+
122
+
123
+ def start_stub() -> ThreadingHTTPServer:
124
+ server = ThreadingHTTPServer(("127.0.0.1", STUB_PORT), _Handler)
125
+ threading.Thread(target=server.serve_forever, daemon=True).start()
126
+ return server
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ async def main() -> int:
133
+ settings = get_settings()
134
+ init_engine(settings)
135
+ server = start_stub()
136
+ touched_subjects: list = []
137
+
138
+ try:
139
+ async with session_scope() as session:
140
+ repository = (await repository_service.list_repositories(session))[0]
141
+ print(f"仓库:{repository.full_name}")
142
+
143
+ # --- 准备:桩凭据 + 桩模型 ---
144
+ credential = await session.scalar(
145
+ select(Credential).where(Credential.name == "stub-llm-key")
146
+ )
147
+ if credential is None:
148
+ credential = await credential_service.create_credential(
149
+ session,
150
+ settings,
151
+ name="stub-llm-key",
152
+ kind=CredentialKind.LLM_API_KEY,
153
+ plaintext="sk-stub-verify",
154
+ )
155
+
156
+ provider = await session.scalar(
157
+ select(LLMProvider).where(LLMProvider.name == PROVIDER_NAME)
158
+ )
159
+ if provider is None:
160
+ provider = LLMProvider(
161
+ name=PROVIDER_NAME,
162
+ base_url=STUB_URL,
163
+ credential_id=credential.id,
164
+ model="stub-model",
165
+ is_default=True,
166
+ )
167
+ session.add(provider)
168
+ await session.flush()
169
+ else:
170
+ provider.enabled = True
171
+ provider.is_default = True
172
+ provider.credential_id = credential.id
173
+
174
+ # --- 选两个 PR 作为分析对象 ---
175
+ #
176
+ # 对象必须是**规则判不出来的** PR,这不是随便挑的:后面要验证
177
+ # "AI 判定扛得住规则重算",而 `reclassify` 的结论是"规则优先、AI
178
+ # 兜底" —— 规则判得出来时它当然写回规则答案,只有判不出来时才回落
179
+ # 到模型此前的结论。拿规则判得出来的 PR 去测这一条,测的是别的东西。
180
+ #
181
+ # 也不能直接问"有哪些待补判的 PR":平台收敛,定时任务每半小时补判
182
+ # 一批,跑久了池子就空了,那句断言会以"找到 0 个"失败 —— 校验结果
183
+ # 取决于任务跑到哪一刻,那是环境,不是被测的代码。
184
+ #
185
+ # 所以自己算:把 open PR 全取出来,现算规则结论,挑出 unknown 的那些。
186
+ # 算完再把它们的分类暂存成 unknown 造出待补判状态,收尾原样还回去。
187
+ # 中途删掉的 AI 分析记录由 touched_subjects 清理。
188
+ #
189
+ # **不能只扫最近更新的那批**:实测最近 200 个恰好全部规则可判
190
+ # (新同步进来的 PR 一旦补齐提交信息,97.7% 都能靠自声明标注判出来),
191
+ # 而判不出来的 181 个散在更早的 PR 里。按更新时间取窗口会时灵时不灵,
192
+ # 又是"结果取决于环境"。全量读 open PR 与兄弟脚本 verify-analysis 一致。
193
+ candidates = list(
194
+ (
195
+ await session.scalars(
196
+ select(PullRequest).where(
197
+ PullRequest.repository_id == repository.id,
198
+ PullRequest.state == "open",
199
+ )
200
+ )
201
+ ).all()
202
+ )
203
+ files = await classification_service.files_for_pulls(
204
+ session, [pull.id for pull in candidates]
205
+ )
206
+ commits = await classification_service.commits_for_pulls(
207
+ session, [pull.id for pull in candidates]
208
+ )
209
+ targets = [
210
+ pull
211
+ for pull in candidates
212
+ if PRKind(
213
+ classification_service.build_result(
214
+ title=pull.title,
215
+ body=pull.body,
216
+ filenames=files.get(pull.id, ()),
217
+ commit_messages=commits.get(pull.id, ()),
218
+ ).kind.value
219
+ )
220
+ is PRKind.UNKNOWN
221
+ ][:2]
222
+ check(
223
+ len(targets) == 2,
224
+ f"在 {len(candidates)} 个 open PR 里找到 {len(targets)} 个规则判不出来的",
225
+ )
226
+ if len(targets) < 2:
227
+ return 1
228
+
229
+ saved: list[tuple[uuid.UUID, dict]] = []
230
+ stale: list[Classification] = []
231
+ for pull in targets:
232
+ touched_subjects.append(pull.id)
233
+ row = await classification_service.get_classification(
234
+ session, SubjectType.PULL_REQUEST, pull.id
235
+ )
236
+ if row is None:
237
+ continue
238
+ saved.append(
239
+ (
240
+ row.id,
241
+ {c.key: getattr(row, c.key) for c in sa_inspect(row).mapper.column_attrs},
242
+ )
243
+ )
244
+ row.kind = PRKind.UNKNOWN
245
+ row.source = ClassificationSource.RULE
246
+ row.confidence = 0
247
+ stale.append(row)
248
+ await session.flush()
249
+
250
+ # flush 之后再把对象从会话里摘掉(收尾时按 id 取回)。顺序不能反:
251
+ # 摘掉一个脏对象会连它待提交的修改一起丢,那两行就根本没变成 unknown。
252
+ #
253
+ # 摘掉是为了避免身份映射挡住后面读到的真实值:`apply_ai_result` 走的是
254
+ # Core 的 pg_insert(...on_conflict_do_update),绕开 ORM,库里改了而
255
+ # 会话里这个对象还是旧值,get_classification 会把 unknown 递回来。
256
+ for row in stale:
257
+ session.expunge(row)
258
+
259
+ missing_now = {
260
+ pull.id
261
+ for pull in await classification_service.pulls_missing_classification(
262
+ session, repository.id, limit=200
263
+ )
264
+ }
265
+ check(
266
+ {pull.id for pull in targets} <= missing_now,
267
+ "两个分析对象现在都处于「待补判」状态",
268
+ )
269
+
270
+ target = targets[0]
271
+
272
+ # --- 正常路径 ---
273
+ #
274
+ # 必须 force:平台按内容哈希去重,同一对象内容没变就不再调用模型
275
+ # (这是省钱的机制)。而这一段的断言全都是关于**请求本身**的 ——
276
+ # 路径、鉴权头、模型名、消息条数 —— 命中缓存就一条请求都不会发,
277
+ # 断言随即变成对着空列表取下标而崩,且崩的地方指向的是
278
+ # "请求路径不对"这件根本不存在的事。补判对象有没有被分析过
279
+ # 取决于 worker 跑到哪一刻,那是环境,不是这里要测的东西。
280
+ STUB.requests.clear()
281
+ STUB.mode = "ok"
282
+ analysis = await ai_service.classify_subject(
283
+ session, settings, repository, pull=target, triggered_by=None, force=True
284
+ )
285
+
286
+ check(
287
+ analysis.status is AnalysisStatus.SUCCEEDED,
288
+ f"分析成功(status={analysis.status.value}, error={analysis.error})",
289
+ )
290
+ check(len(STUB.requests) == 1, f"恰好发出 1 次请求(实际 {len(STUB.requests)})")
291
+
292
+ sent = STUB.requests[0]
293
+ check(sent["path"] == "/v1/chat/completions", f"请求路径 {sent['path']}")
294
+ check(
295
+ sent["headers"].get("Authorization") == "Bearer sk-stub-verify",
296
+ "使用解密后的凭据作为 Bearer token",
297
+ )
298
+ check(sent["body"]["model"] == "stub-model", "模型名来自 provider 配置")
299
+ check(sent["body"]["stream"] is False, "非流式调用")
300
+ check(
301
+ sent["body"]["response_format"] == {"type": "json_object"},
302
+ "分类任务要求上游返回 JSON",
303
+ )
304
+ check(len(sent["body"]["messages"]) == 2, "system + user 两条消息")
305
+ # 分类是轻任务,Prompt 刻意不带 KABI/补丁格式那套领域规则:
306
+ # 判定"属于哪一类"用不上它们,带上只是把每次调用的成本抬高。
307
+ # 真正必须有的是取值清单 —— 否则模型会自造类型。
308
+ system_prompt = sent["body"]["messages"][0]["content"]
309
+ check(
310
+ all(kind in system_prompt for kind in ("cve", "backport", "bugfix", "feature")),
311
+ "system prompt 列出全部可选类型(约束模型不得自造)",
312
+ )
313
+ print(
314
+ f" → prompt_tokens={analysis.prompt_tokens} "
315
+ f"completion_tokens={analysis.completion_tokens}"
316
+ )
317
+
318
+ # --- 结果写回分类表 ---
319
+ row = await classification_service.get_classification(
320
+ session, SubjectType.PULL_REQUEST, target.id
321
+ )
322
+ check(row is not None, "分类记录存在")
323
+ check(
324
+ row.source is ClassificationSource.AI,
325
+ f"分类来源标记为 ai(实际 {row.source.value})",
326
+ )
327
+ check(row.kind is PRKind.BUGFIX, f"类型来自模型输出({row.kind.value})")
328
+ check(row.subsystem == "net", f"子系统已归一化({row.subsystem})")
329
+ check(0 < row.confidence < 1, f"置信度记录为模型的 0.82({row.confidence})")
330
+
331
+ # --- 幂等:内容未变不重复调用模型 ---
332
+ STUB.requests.clear()
333
+ again = await ai_service.classify_subject(session, settings, repository, pull=target)
334
+ check(
335
+ len(STUB.requests) == 0,
336
+ f"输入未变时不重复调用模型(实际发出 {len(STUB.requests)} 次)",
337
+ )
338
+ check(again.id == analysis.id, "返回同一条分析记录")
339
+
340
+ # --- 强制重跑必须真的重跑 ---
341
+ STUB.requests.clear()
342
+ await ai_service.classify_subject(
343
+ session, settings, repository, pull=target, force=True
344
+ )
345
+ check(len(STUB.requests) == 1, "force=True 时绕过幂等检查")
346
+
347
+ # --- 人工覆盖优先:自动任务不得改写 ---
348
+ await classification_service.override(
349
+ session,
350
+ row,
351
+ kind=PRKind.FEATURE,
352
+ subsystem="mm",
353
+ user_id=(await session.scalar(select(Credential.created_by))) or None,
354
+ )
355
+ await session.flush()
356
+ await classification_service.classify_pulls(session, repository, [target])
357
+ refreshed = await classification_service.get_classification(
358
+ session, SubjectType.PULL_REQUEST, target.id
359
+ )
360
+ check(
361
+ refreshed.kind is PRKind.FEATURE and refreshed.source.value == "manual",
362
+ f"人工覆盖不被规则重算改写(当前 {refreshed.kind.value}/{refreshed.source.value})",
363
+ )
364
+ STUB.requests.clear()
365
+ await ai_service.classify_subject(session, settings, repository, pull=target)
366
+ refreshed = await classification_service.get_classification(
367
+ session, SubjectType.PULL_REQUEST, target.id
368
+ )
369
+ check(
370
+ refreshed.kind is PRKind.FEATURE,
371
+ "人工覆盖同样不被 AI 结果改写",
372
+ )
373
+ await classification_service.clear_override(session, refreshed)
374
+
375
+ # --- AI 判定必须扛得住规则重算 ---
376
+ #
377
+ # 规则每 10 分钟全量重算一次。若允许它把 AI 判出的结果覆盖回
378
+ # unknown,那条记录下一轮又会被挑去"补判" —— 模型会被反复
379
+ # 调用同一个对象,既不收敛也持续计费。
380
+ await classification_service.classify_pulls(session, repository, targets)
381
+ after_rule_pass = await classification_service.get_classification(
382
+ session, SubjectType.PULL_REQUEST, target.id
383
+ )
384
+ check(
385
+ after_rule_pass.source is ClassificationSource.AI
386
+ and after_rule_pass.kind is PRKind.BUGFIX,
387
+ f"规则重算不打回 AI 判定(当前 {after_rule_pass.source.value}/"
388
+ f"{after_rule_pass.kind.value})",
389
+ )
390
+
391
+ still_missing = await classification_service.pulls_missing_classification(
392
+ session, repository.id, limit=200
393
+ )
394
+ check(
395
+ target.id not in {pull.id for pull in still_missing},
396
+ "AI 判定过的条目不再出现在待补判列表里(不会重复计费)",
397
+ )
398
+
399
+ # --- 畸形输出:模型加围栏 + 前言 ---
400
+ STUB.mode = "garbage"
401
+ STUB.requests.clear()
402
+ second = targets[1] if len(targets) > 1 else target
403
+ garbage = await ai_service.classify_subject(
404
+ session, settings, repository, pull=second, force=True
405
+ )
406
+ check(
407
+ garbage.status is AnalysisStatus.SUCCEEDED,
408
+ f"散文包裹的 JSON 能被解析(status={garbage.status.value})",
409
+ )
410
+ check(
411
+ garbage.result.get("kind") == "feature", f"解析出 kind={garbage.result.get('kind')}"
412
+ )
413
+
414
+ # --- 上游拒绝:失败要落状态,且不向上抛 ---
415
+ STUB.mode = "auth_error"
416
+ STUB.requests.clear()
417
+ failed = await ai_service.classify_subject(
418
+ session, settings, repository, pull=second, force=True
419
+ )
420
+ check(
421
+ failed.status is AnalysisStatus.FAILED,
422
+ f"401 使分析标记为 failed(实际 {failed.status.value})",
423
+ )
424
+ check(bool(failed.error), f"失败原因已记录:{(failed.error or '')[:60]}")
425
+ check(
426
+ failed.finished_at is not None and failed.started_at is not None,
427
+ "失败也记录起止时间(用于统计耗时)",
428
+ )
429
+ # 401 是确定性失败,重试只会把错误的凭据再送两遍
430
+ check(
431
+ len(STUB.requests) == 1,
432
+ f"401 不重试(实际请求 {len(STUB.requests)} 次)",
433
+ )
434
+
435
+ token_sum = sum(
436
+ a.total_tokens
437
+ for a in (
438
+ await session.scalars(
439
+ select(AIAnalysis).where(AIAnalysis.repository_id == repository.id)
440
+ )
441
+ ).all()
442
+ )
443
+ print(f" → 累计 token:{token_sum}")
444
+
445
+ # --- 模型自造枚举值:显式拒绝,且不写坏分类表 ---
446
+ STUB.mode = "bad_kind"
447
+ STUB.requests.clear()
448
+ before = await classification_service.get_classification(
449
+ session, SubjectType.PULL_REQUEST, second.id
450
+ )
451
+ before_kind = before.kind if before else None
452
+ bogus = await ai_service.classify_subject(
453
+ session, settings, repository, pull=second, force=True
454
+ )
455
+ after = await classification_service.get_classification(
456
+ session, SubjectType.PULL_REQUEST, second.id
457
+ )
458
+ check(
459
+ bogus.status is AnalysisStatus.SUCCEEDED,
460
+ "模型返回的是合法 JSON,分析本身算成功",
461
+ )
462
+ check(
463
+ bogus.result.get("kind") == "perf_optimization",
464
+ "原始响应如实保存,便于事后追查模型说了什么",
465
+ )
466
+ check(
467
+ (after.kind if after else None) == before_kind,
468
+ f"非法类型不写入分类表(仍为 {before_kind.value if before_kind else None})",
469
+ )
470
+
471
+ # --- 未配置模型时的行为 ---
472
+ #
473
+ # 必须**抛错**而不是落一条 FAILED:未配置是部署问题,不是分析失败。
474
+ # 若落库,定时任务每轮都会写几条"失败",用量面板上看着像模型故障,
475
+ # 实际只是还没配;这些空记录还会一直累积。
476
+ # 桩 provider 未必是库里唯一启用的一个 —— 仓库纳管后可能已经配了
477
+ # 真实端点。只关桩的那个,"无可用模型"这个前提就不成立,检查结果
478
+ # 于是取决于本机环境而非代码。这里暂时关掉全部,断言后原样恢复:
479
+ # 校验脚本不该把本机的端点配置改掉。
480
+ enabled_before = list(
481
+ (await session.scalars(select(LLMProvider).where(LLMProvider.enabled.is_(True)))).all()
482
+ )
483
+ for row in enabled_before:
484
+ row.enabled = False
485
+ await session.flush()
486
+ STUB.mode = "ok"
487
+ STUB.requests.clear()
488
+
489
+ rows_before = await session.scalar(
490
+ select(func.count())
491
+ .select_from(AIAnalysis)
492
+ .where(AIAnalysis.repository_id == repository.id)
493
+ )
494
+ raised = ""
495
+ try:
496
+ await ai_service.classify_subject(
497
+ session, settings, repository, pull=second, force=True
498
+ )
499
+ except ValidationError as exc:
500
+ raised = str(exc)
501
+
502
+ check("尚未配置" in raised, f"无可用模型时抛出明确提示:{raised[:40]}")
503
+ check(len(STUB.requests) == 0, "无可用模型时不发请求")
504
+
505
+ # 恢复本机原有的端点配置,并让桩 provider 重新可用
506
+ for row in enabled_before:
507
+ row.enabled = True
508
+ provider.enabled = True
509
+ await session.flush()
510
+
511
+ rows_after = await session.scalar(
512
+ select(func.count())
513
+ .select_from(AIAnalysis)
514
+ .where(AIAnalysis.repository_id == repository.id)
515
+ )
516
+ check(
517
+ rows_after == rows_before,
518
+ f"无可用模型时不留下失败记录({rows_before} → {rows_after})",
519
+ )
520
+
521
+ # --- 并发:定时任务与人工重跑撞上同一个对象 ---
522
+ #
523
+ # 唯一约束 (subject_type, subject_id, task) 规定一个对象只有一行,
524
+ # 而 execute() 是"先查再插"。定时任务和用户点「重新分析」可能同时
525
+ # 选中同一个对象:两边的 SELECT 都看不见对方还没提交的行,都去插入,
526
+ # 后到的被约束拦下。生产上就是这么炸的 —— 实测到 worker 用真实端点
527
+ # (v100-llm-local)写的那一行,与脚本自己那次插入撞在同一个 key 上。
528
+ #
529
+ # 竞态没法在脚本里稳定复现,但它的**本质条件**可以:让 SELECT 明明
530
+ # 有一次看不见那行已经存在的记录。这里用一次性的 monkeypatch 造出
531
+ # 这个条件,比拉两个进程去撞时间窗可靠。
532
+ real_get = ai_service.get_analysis
533
+ hidden = {"done": False}
534
+
535
+ async def _hide_once(session_, subject_type, subject_id, task): # noqa: ANN001
536
+ found = await real_get(session_, subject_type, subject_id, task)
537
+ if found is not None and not hidden["done"]:
538
+ hidden["done"] = True
539
+ return None
540
+ return found
541
+
542
+ ai_service.get_analysis = _hide_once
543
+ STUB.mode = "ok"
544
+ STUB.requests.clear()
545
+ crashed = ""
546
+ try:
547
+ raced = await ai_service.classify_subject(
548
+ session, settings, repository, pull=second, force=True
549
+ )
550
+ except Exception as exc: # noqa: BLE001
551
+ crashed = f"{type(exc).__name__}: {exc}"
552
+ raced = None
553
+ finally:
554
+ ai_service.get_analysis = real_get
555
+
556
+ check(hidden["done"], "竞态条件已造出(有一次 SELECT 看不见已存在的行)")
557
+ check(not crashed, f"并发插入被接住而不是抛出去:{crashed[:60]}")
558
+ check(
559
+ raced is not None and raced.status is AnalysisStatus.SUCCEEDED,
560
+ f"撞上后仍完成分析(status={raced.status.value if raced else None})",
561
+ )
562
+ dup = await session.scalar(
563
+ select(func.count())
564
+ .select_from(AIAnalysis)
565
+ .where(
566
+ AIAnalysis.subject_id == second.id,
567
+ AIAnalysis.task == AITask.CLASSIFY,
568
+ )
569
+ )
570
+ check(dup == 1, f"仍然只有一行分析记录({dup})")
571
+
572
+ # 会话必须还能用:worker 一个会话要连着分析几十个对象,
573
+ # 一次冲突把会话作废就等于整批白跑。
574
+ usable = await session.scalar(select(func.count()).select_from(AIAnalysis))
575
+ check(usable is not None, "冲突之后会话仍可继续使用")
576
+
577
+ # --- 把分类还原 ---
578
+ #
579
+ # 开头为了造出"待补判"而暂存的那两行,逐字段写回去。不还原的话
580
+ # 这台机器上会平白多出两条 unknown,规则重算虽然能救回来一部分,
581
+ # 但 AI 判出的类别(比如 perf)会被打回未知并重新计费。
582
+ for classification_id, values in saved:
583
+ row = await session.get(Classification, classification_id)
584
+ if row is None:
585
+ continue
586
+ for key, value in values.items():
587
+ setattr(row, key, value)
588
+ await session.flush()
589
+ print(f" ok 已还原 {len(saved)} 条分类记录")
590
+
591
+ # --- 清理 ---
592
+ #
593
+ # 按 subject_id 删而不是按 provider_name:未配置模型时那条失败记录
594
+ # 的 provider_name 是 NULL,按 provider_name 过滤会把它留在库里,
595
+ # 界面上就会凭空多出几条"失败"的用量统计。
596
+ async with session_scope() as session:
597
+ for subject_id in touched_subjects:
598
+ await session.execute(delete(AIAnalysis).where(AIAnalysis.subject_id == subject_id))
599
+ await session.execute(delete(LLMProvider).where(LLMProvider.name == PROVIDER_NAME))
600
+ print(" ok 已清理桩数据")
601
+ finally:
602
+ server.shutdown()
603
+ await dispose_engine()
604
+
605
+ print()
606
+ if failures:
607
+ print(f"失败 {len(failures)} 项:")
608
+ for message in failures:
609
+ print(f" - {message}")
610
+ return 1
611
+ print("全部通过")
612
+ return 0
613
+
614
+
615
+ if __name__ == "__main__":
616
+ sys.exit(asyncio.run(main()))