@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,658 @@
1
+ """分类服务。
2
+
3
+ 职责:把 domain.classification 的纯规则结果落库,并维护"人工覆盖优先"的契约。
4
+
5
+ 三档优先级的处理是这套机制的核心:
6
+ 1. 规则命中 → 直接写入,置信度由规则给出
7
+ 2. 规则未命中 → 记为 unknown,由 AI 任务补判(见 ai_service)
8
+ 3. 人工覆盖 → ``is_override`` 置位,此后任何自动任务都不再改写
9
+
10
+ 第 3 条不是可选优化。维护者手动纠正过一次分类,下次同步又被改回去,
11
+ 这套分类就没人会再信任。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from collections.abc import Sequence
18
+ from datetime import UTC, datetime
19
+
20
+ import structlog
21
+ from sqlalchemy import Select, func, or_, select
22
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
23
+ from sqlalchemy.ext.asyncio import AsyncSession
24
+
25
+ from app.domain import classification as domain
26
+ from app.models.ai import AIAnalysis, AITask, AnalysisStatus
27
+ from app.models.classification import (
28
+ Classification,
29
+ ClassificationSource,
30
+ PRKind,
31
+ SubjectType,
32
+ )
33
+ from app.models.issue import Issue
34
+ from app.models.pull_request import PRCommit, PRFile, PullRequest
35
+ from app.models.repository import Repository
36
+
37
+ logger = structlog.get_logger(__name__)
38
+
39
+ # 分类结果与 PR 快照一起批量写入;一次同步可能带来上千条
40
+ BATCH_SIZE = 500
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # 结果构造(纯函数,便于单测)
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ def build_result(
49
+ *,
50
+ title: str,
51
+ body: str | None,
52
+ filenames: Sequence[str] = (),
53
+ commit_messages: Sequence[str] = (),
54
+ ) -> domain.ClassifyResult:
55
+ """综合标题、正文、提交信息与变更文件得到分类结果。
56
+
57
+ 提交信息是这里最可靠的信号:openEuler 的补丁在提交信息末尾带
58
+ ``category:`` 与 ``<X> inclusion`` 自声明标注(实测覆盖率 97.7%),
59
+ 比从标题猜关键词准确得多。但它只在详情同步完成后才有,
60
+ 因此标题规则仍是必需的降级路径。
61
+
62
+ 子系统归属优先取变更文件 —— 文件路径是客观事实,标题前缀是作者的自由表述。
63
+ """
64
+ result = domain.classify_by_rules(title, body or "", commit_messages)
65
+
66
+ primary, related = domain.derive_subsystem(list(filenames))
67
+ if primary is None:
68
+ primary = domain.normalize_subsystem(result.title_subsystem)
69
+ # 标题声明的子系统也补进相关列表:文件列表缺失时它是唯一线索,
70
+ # 文件列表存在时它代表作者意图,两者都值得保留
71
+ declared = domain.normalize_subsystem(result.title_subsystem)
72
+ if declared and declared != primary and declared not in related:
73
+ related = [declared, *related][:3]
74
+
75
+ result.subsystem = primary
76
+ result.related_subsystems = related
77
+ return result
78
+
79
+
80
+ def _row(
81
+ repository_id: uuid.UUID,
82
+ subject_type: SubjectType,
83
+ subject_id: uuid.UUID,
84
+ result: domain.ClassifyResult,
85
+ *,
86
+ source: ClassificationSource = ClassificationSource.RULE,
87
+ rule_name: str | None = None,
88
+ ) -> dict:
89
+ return {
90
+ "id": uuid.uuid4(),
91
+ "repository_id": repository_id,
92
+ "subject_type": subject_type.value,
93
+ "subject_id": subject_id,
94
+ "kind": PRKind(result.kind.value).value,
95
+ "subsystem": result.subsystem,
96
+ "related_subsystems": result.related_subsystems or None,
97
+ "cve_ids": result.cve_ids or None,
98
+ "linked_issues": result.linked_issues or None,
99
+ "confidence": result.confidence,
100
+ "source": source.value,
101
+ "rule_name": rule_name if rule_name is not None else result.rule_name,
102
+ "reason": result.reason,
103
+ "is_override": False,
104
+ }
105
+
106
+
107
+ # 冲突时允许改写的列。刻意不含 is_override / overridden_by ——
108
+ # 自动任务不得自行解除人工覆盖标记。
109
+ _UPSERT_COLUMNS = (
110
+ "kind",
111
+ "subsystem",
112
+ "related_subsystems",
113
+ "cve_ids",
114
+ "linked_issues",
115
+ "confidence",
116
+ "source",
117
+ "rule_name",
118
+ "reason",
119
+ )
120
+
121
+
122
+ async def _upsert(session: AsyncSession, rows: list[dict], *, preserve_ai: bool = False) -> int:
123
+ """批量写入分类结果。
124
+
125
+ 两类记录不被改写:
126
+ - 人工覆盖(``is_override``)
127
+ - AI 判定,且本轮规则也没判出来(``preserve_ai=True`` 时)
128
+
129
+ 第二条是必须的。规则每 10 分钟重算一次;若允许它把 AI 判出的结果
130
+ 覆盖回 ``unknown``,那条记录下一轮又会被判定为"需要 AI 补判",
131
+ 于是模型被反复调用同一个对象 —— 一个持续烧钱且永不收敛的循环。
132
+
133
+ ``updated_at`` 只在语义列真的变了才推进。规则每 10 分钟全量重算一次,
134
+ 无条件推进会让全部记录恒久处于"刚刚更新",而分类列表正按这一列排序 ——
135
+ 那样排序就退化成了随机序。
136
+ """
137
+ if not rows:
138
+ return 0
139
+
140
+ written = 0
141
+ for start in range(0, len(rows), BATCH_SIZE):
142
+ chunk = rows[start : start + BATCH_SIZE]
143
+ stmt = pg_insert(Classification).values(chunk)
144
+ condition = Classification.is_override.is_(False)
145
+ if preserve_ai:
146
+ condition = condition & (Classification.source != ClassificationSource.AI)
147
+ changed = or_(
148
+ *(
149
+ getattr(Classification, col).is_distinct_from(getattr(stmt.excluded, col))
150
+ for col in _UPSERT_COLUMNS
151
+ )
152
+ )
153
+ stmt = stmt.on_conflict_do_update(
154
+ constraint="uq_ksp_classification_subject",
155
+ set_={
156
+ **{col: getattr(stmt.excluded, col) for col in _UPSERT_COLUMNS},
157
+ "updated_at": func.now(),
158
+ },
159
+ where=condition & changed,
160
+ )
161
+ await session.execute(stmt)
162
+ written += len(chunk)
163
+ return written
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # 分类执行
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ async def files_for_pulls(
172
+ session: AsyncSession, pull_ids: Sequence[uuid.UUID]
173
+ ) -> dict[uuid.UUID, list[str]]:
174
+ """批量取变更文件名,避免按 PR 逐条查询。"""
175
+ if not pull_ids:
176
+ return {}
177
+ stmt = select(PRFile.pull_request_id, PRFile.filename).where(
178
+ PRFile.pull_request_id.in_(list(pull_ids))
179
+ )
180
+ result: dict[uuid.UUID, list[str]] = {}
181
+ for pull_id, filename in (await session.execute(stmt)).all():
182
+ result.setdefault(pull_id, []).append(filename)
183
+ return result
184
+
185
+
186
+ async def _write_split(session: AsyncSession, rows: list[dict]) -> int:
187
+ """按"规则是否判出来"分组写入。
188
+
189
+ 规则命中时置信度是确定的(比如标题里有 CVE 编号),此时它可以推翻
190
+ 一个先前由 AI 给出的判断;规则没命中时它只代表"我不知道",
191
+ 那就没有理由去覆盖 AI 已经给出的答案。
192
+ """
193
+ matched = [row for row in rows if row["kind"] != PRKind.UNKNOWN.value]
194
+ undecided = [row for row in rows if row["kind"] == PRKind.UNKNOWN.value]
195
+ written = await _upsert(session, matched)
196
+ written += await _upsert(session, undecided, preserve_ai=True)
197
+ return written
198
+
199
+
200
+ async def commits_for_pulls(
201
+ session: AsyncSession, pull_ids: Sequence[uuid.UUID]
202
+ ) -> dict[uuid.UUID, list[str]]:
203
+ """批量取提交信息,供分类读取作者的自声明标注。"""
204
+ if not pull_ids:
205
+ return {}
206
+ stmt = select(PRCommit.pull_request_id, PRCommit.message).where(
207
+ PRCommit.pull_request_id.in_(list(pull_ids))
208
+ )
209
+ result: dict[uuid.UUID, list[str]] = {}
210
+ for pull_id, message in (await session.execute(stmt)).all():
211
+ result.setdefault(pull_id, []).append(message)
212
+ return result
213
+
214
+
215
+ async def classify_pulls(
216
+ session: AsyncSession, repository: Repository, pulls: Sequence[PullRequest]
217
+ ) -> int:
218
+ """对一个仓库的一批 PR 执行规则分类,返回处理条数。
219
+
220
+ 整体幂等:重复执行结果相同;人工覆盖与 AI 判定不会被打回。
221
+ """
222
+ if not pulls:
223
+ return 0
224
+
225
+ ids = [pull.id for pull in pulls]
226
+ files = await files_for_pulls(session, ids)
227
+ commits = await commits_for_pulls(session, ids)
228
+ rows = [
229
+ _row(
230
+ repository.id,
231
+ SubjectType.PULL_REQUEST,
232
+ pull.id,
233
+ build_result(
234
+ title=pull.title,
235
+ body=pull.body,
236
+ filenames=files.get(pull.id, ()),
237
+ commit_messages=commits.get(pull.id, ()),
238
+ ),
239
+ )
240
+ for pull in pulls
241
+ ]
242
+ written = await _write_split(session, rows)
243
+ logger.info(
244
+ "pulls_classified",
245
+ repository=repository.full_name,
246
+ count=written,
247
+ )
248
+ return written
249
+
250
+
251
+ async def classify_issues(
252
+ session: AsyncSession, repository: Repository, issues: Sequence[Issue]
253
+ ) -> int:
254
+ """对 Issue 执行规则分类。
255
+
256
+ Issue 没有变更文件,子系统只能来自标题前缀或 Issue 类型。
257
+ """
258
+ if not issues:
259
+ return 0
260
+
261
+ rows = [
262
+ _row(
263
+ repository.id,
264
+ SubjectType.ISSUE,
265
+ issue.id,
266
+ build_result(title=issue.title, body=issue.body),
267
+ )
268
+ for issue in issues
269
+ ]
270
+ return await _write_split(session, rows)
271
+
272
+
273
+ def normalize_ai_kind(kind: PRKind, reason: str | None) -> tuple[PRKind, str | None]:
274
+ """把模型给出的类型收敛到平台能够自证的取值。
275
+
276
+ 只处理一种情况,但它是必然发生的:**模型判 cve 时给不出 CVE 编号**。
277
+ 提示词把 cve 定义为"修复安全漏洞(出现 CVE 编号)",而规则层只要在正文里
278
+ 匹配到编号就直接判 cve —— 所以走到 AI 补判这一步的对象,正文里本来就没有
279
+ 编号。实测到的两条正是如此:一条是给 ioctl 加权限检查的加固,一条是漏洞
280
+ 影响评估,模型都凭"这是安全修复"判成了 cve。
281
+
282
+ 两条路都不能走:留着这个 cve,版本汇总里对外报的「CVE 数」就混进了未编号
283
+ 的安全加固,而那是要拿去汇报的数字;直接丢弃则会让它一直停在 unknown,
284
+ 每轮补判都重新调用一次模型 —— AI 调用是本平台唯一按次计费的开销。
285
+ 因此降为 bugfix(没有编号的安全修复,就是一次缺陷修复),并把这一步写进
286
+ 理由里,让它可追溯而不是被悄悄改掉。
287
+ """
288
+ if kind is not PRKind.CVE:
289
+ return kind, reason
290
+ note = "模型判为安全修复但正文无 CVE 编号,按缺陷修复记录"
291
+ return PRKind.BUGFIX, f"{reason}({note})" if reason else note
292
+
293
+
294
+ async def apply_ai_result(
295
+ session: AsyncSession,
296
+ *,
297
+ repository_id: uuid.UUID,
298
+ subject_type: SubjectType,
299
+ subject_id: uuid.UUID,
300
+ kind: str,
301
+ subsystem: str | None = None,
302
+ confidence: float = 0.0,
303
+ reason: str | None = None,
304
+ ) -> bool:
305
+ """写入 AI 判定结果。返回是否真的写入。
306
+
307
+ 只有 AI 给出的类型合法时才写入 —— 模型偶尔会自造一个不在枚举里的取值,
308
+ 直接入库会抛错,静默截断成 unknown 又会掩盖问题,因此显式拒绝并记录。
309
+ """
310
+ try:
311
+ resolved = PRKind(kind)
312
+ except ValueError:
313
+ logger.warning("ai_classification_invalid_kind", kind=kind, subject_id=str(subject_id))
314
+ return False
315
+
316
+ resolved, reason = normalize_ai_kind(resolved, reason)
317
+ normalized = domain.normalize_subsystem(subsystem)
318
+ row = {
319
+ "id": uuid.uuid4(),
320
+ "repository_id": repository_id,
321
+ "subject_type": subject_type.value,
322
+ "subject_id": subject_id,
323
+ "kind": resolved.value,
324
+ "subsystem": normalized,
325
+ "related_subsystems": None,
326
+ "cve_ids": None,
327
+ "linked_issues": None,
328
+ "confidence": confidence,
329
+ "source": ClassificationSource.AI.value,
330
+ "rule_name": "ai",
331
+ "reason": reason,
332
+ "is_override": False,
333
+ }
334
+ stmt = pg_insert(Classification).values(row)
335
+ stmt = stmt.on_conflict_do_update(
336
+ constraint="uq_ksp_classification_subject",
337
+ set_={col: getattr(stmt.excluded, col) for col in _UPSERT_COLUMNS},
338
+ where=Classification.is_override.is_(False),
339
+ )
340
+ await session.execute(stmt)
341
+ return True
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # 人工覆盖
346
+ # ---------------------------------------------------------------------------
347
+
348
+
349
+ async def override(
350
+ session: AsyncSession,
351
+ classification: Classification,
352
+ *,
353
+ kind: PRKind,
354
+ subsystem: str | None,
355
+ user_id: uuid.UUID,
356
+ ) -> Classification:
357
+ """人工指定分类。此后自动任务不再改写这条记录。"""
358
+ classification.kind = kind
359
+ classification.subsystem = subsystem
360
+ classification.source = ClassificationSource.MANUAL
361
+ classification.confidence = 1.0
362
+ classification.rule_name = "manual"
363
+ classification.reason = "人工指定"
364
+ classification.is_override = True
365
+ classification.overridden_by = user_id
366
+ classification.overridden_at = datetime.now(UTC)
367
+ await session.flush()
368
+ return classification
369
+
370
+
371
+ async def clear_override(session: AsyncSession, classification: Classification) -> Classification:
372
+ """撤销人工覆盖,立刻交还给自动分类。
373
+
374
+ 只清标记是不够的:留下的还是人工设定的那个类型,而规则重算最长要等
375
+ 10 分钟才跑下一轮 —— 使用者点了「撤销」却看到类型没变,
376
+ 只会以为这个按钮没生效。因此这里就地重跑一次规则。
377
+ """
378
+ classification.is_override = False
379
+ classification.overridden_by = None
380
+ classification.overridden_at = None
381
+ await session.flush()
382
+ await reclassify(session, classification)
383
+ return classification
384
+
385
+
386
+ async def reclassify(session: AsyncSession, classification: Classification) -> Classification:
387
+ """重算单条记录,得出"自动流程会给的答案"。已被人工覆盖的记录不动。
388
+
389
+ 自动流程的结论按 **规则优先、AI 兜底** 得出,这里必须复现同一条链:
390
+ 规则判得出来就用规则,判不出来时回落到该对象已有的 AI 判定,
391
+ 而不是一律写成 ``unknown``。
392
+
393
+ 否则「撤销人工指定」会连带丢掉模型此前的结论,那条记录重新落回
394
+ 待补判池、再被送去调用一次模型 —— 撤销一个手工纠正不该产生账单。
395
+ """
396
+ if classification.is_override:
397
+ return classification
398
+
399
+ subject_type = SubjectType(classification.subject_type)
400
+ if subject_type is SubjectType.PULL_REQUEST:
401
+ pull = await session.get(PullRequest, classification.subject_id)
402
+ if pull is None:
403
+ return classification
404
+ files = (await files_for_pulls(session, [pull.id])).get(pull.id, ())
405
+ commits = (await commits_for_pulls(session, [pull.id])).get(pull.id, ())
406
+ result = build_result(
407
+ title=pull.title,
408
+ body=pull.body,
409
+ filenames=files,
410
+ commit_messages=commits,
411
+ )
412
+ else:
413
+ issue = await session.get(Issue, classification.subject_id)
414
+ if issue is None:
415
+ return classification
416
+ result = build_result(title=issue.title, body=issue.body)
417
+
418
+ if result.kind is domain.Kind.UNKNOWN:
419
+ fallback = await _latest_ai_result(session, classification)
420
+ if fallback is not None:
421
+ _apply_ai_row(classification, fallback)
422
+ # 会话未开启 autoflush,必须显式落盘 ——
423
+ # 否则紧随其后的查询(如"待补判列表")读到的还是旧值
424
+ await session.flush()
425
+ return classification
426
+
427
+ classification.kind = PRKind(result.kind.value)
428
+ classification.subsystem = result.subsystem
429
+ classification.related_subsystems = result.related_subsystems or None
430
+ classification.cve_ids = result.cve_ids or None
431
+ classification.linked_issues = result.linked_issues or None
432
+ classification.confidence = result.confidence
433
+ classification.source = ClassificationSource.RULE
434
+ classification.rule_name = result.rule_name
435
+ classification.reason = result.reason
436
+ await session.flush()
437
+ return classification
438
+
439
+
440
+ async def _latest_ai_result(
441
+ session: AsyncSession, classification: Classification
442
+ ) -> AIAnalysis | None:
443
+ """该对象最近一次成功的 AI 类型判定。"""
444
+ stmt = (
445
+ select(AIAnalysis)
446
+ .where(
447
+ AIAnalysis.subject_type == classification.subject_type,
448
+ AIAnalysis.subject_id == classification.subject_id,
449
+ AIAnalysis.task == AITask.CLASSIFY,
450
+ AIAnalysis.status == AnalysisStatus.SUCCEEDED,
451
+ AIAnalysis.result.is_not(None),
452
+ )
453
+ .order_by(AIAnalysis.finished_at.desc())
454
+ .limit(1)
455
+ )
456
+ return await session.scalar(stmt)
457
+
458
+
459
+ def _apply_ai_row(classification: Classification, analysis: AIAnalysis) -> Classification:
460
+ """把一条 AI 分析结果写回分类记录。模型自造的类型一律不采纳。"""
461
+ result = analysis.result or {}
462
+ try:
463
+ kind = PRKind(str(result.get("kind", "")))
464
+ except ValueError:
465
+ return classification
466
+
467
+ kind, reason = normalize_ai_kind(kind, result.get("reason"))
468
+ classification.kind = kind
469
+ classification.subsystem = domain.normalize_subsystem(result.get("subsystem"))
470
+ classification.confidence = float(result.get("confidence") or 0.0)
471
+ classification.source = ClassificationSource.AI
472
+ classification.rule_name = "ai"
473
+ classification.reason = reason
474
+ return classification
475
+
476
+
477
+ # ---------------------------------------------------------------------------
478
+ # 查询
479
+ # ---------------------------------------------------------------------------
480
+
481
+
482
+ async def get_classification(
483
+ session: AsyncSession, subject_type: SubjectType, subject_id: uuid.UUID
484
+ ) -> Classification | None:
485
+ stmt = select(Classification).where(
486
+ Classification.subject_type == subject_type,
487
+ Classification.subject_id == subject_id,
488
+ )
489
+ return await session.scalar(stmt)
490
+
491
+
492
+ async def classifications_for(
493
+ session: AsyncSession,
494
+ subject_type: SubjectType,
495
+ subject_ids: Sequence[uuid.UUID],
496
+ ) -> dict[uuid.UUID, Classification]:
497
+ """批量取分类结果,供列表页一次性渲染,避免 N+1。"""
498
+ if not subject_ids:
499
+ return {}
500
+ stmt = select(Classification).where(
501
+ Classification.subject_type == subject_type,
502
+ Classification.subject_id.in_(list(subject_ids)),
503
+ )
504
+ return {row.subject_id: row for row in (await session.scalars(stmt)).all()}
505
+
506
+
507
+ def _filtered(
508
+ stmt: Select,
509
+ *,
510
+ repository_id: uuid.UUID | None,
511
+ subject_type: SubjectType | None,
512
+ kind: PRKind | None,
513
+ subsystem: str | None,
514
+ source: ClassificationSource | None,
515
+ include_override_only: bool,
516
+ ) -> Select:
517
+ stmt = stmt.where(Classification.repository_id == repository_id) if repository_id else stmt
518
+ if subject_type is not None:
519
+ stmt = stmt.where(Classification.subject_type == subject_type)
520
+ if kind is not None:
521
+ stmt = stmt.where(Classification.kind == kind)
522
+ if subsystem:
523
+ stmt = stmt.where(Classification.subsystem == subsystem)
524
+ if source is not None:
525
+ stmt = stmt.where(Classification.source == source)
526
+ if include_override_only:
527
+ stmt = stmt.where(Classification.is_override.is_(True))
528
+ return stmt
529
+
530
+
531
+ async def list_classifications(
532
+ session: AsyncSession,
533
+ *,
534
+ repository_id: uuid.UUID | None = None,
535
+ subject_type: SubjectType | None = None,
536
+ kind: PRKind | None = None,
537
+ subsystem: str | None = None,
538
+ source: ClassificationSource | None = None,
539
+ override_only: bool = False,
540
+ limit: int = 50,
541
+ offset: int = 0,
542
+ ) -> tuple[list[Classification], int]:
543
+ base = _filtered(
544
+ select(Classification),
545
+ repository_id=repository_id,
546
+ subject_type=subject_type,
547
+ kind=kind,
548
+ subsystem=subsystem,
549
+ source=source,
550
+ include_override_only=override_only,
551
+ )
552
+
553
+ count_stmt = _filtered(
554
+ select(func.count(Classification.id)),
555
+ repository_id=repository_id,
556
+ subject_type=subject_type,
557
+ kind=kind,
558
+ subsystem=subsystem,
559
+ source=source,
560
+ include_override_only=override_only,
561
+ )
562
+
563
+ stmt = base.order_by(Classification.updated_at.desc()).limit(limit).offset(offset)
564
+ rows = list((await session.scalars(stmt)).all())
565
+ total = await session.scalar(count_stmt) or 0
566
+ return rows, total
567
+
568
+
569
+ async def kind_distribution(
570
+ session: AsyncSession,
571
+ repository_id: uuid.UUID,
572
+ *,
573
+ subject_type: SubjectType = SubjectType.PULL_REQUEST,
574
+ ) -> list[dict]:
575
+ """按类型统计。供仪表盘与筛选栏直接展示,无需再算。"""
576
+ stmt = (
577
+ select(Classification.kind, func.count())
578
+ .where(
579
+ Classification.repository_id == repository_id,
580
+ Classification.subject_type == subject_type,
581
+ )
582
+ .group_by(Classification.kind)
583
+ .order_by(func.count().desc())
584
+ )
585
+ return [{"kind": row[0].value, "count": row[1]} for row in (await session.execute(stmt)).all()]
586
+
587
+
588
+ async def subsystem_distribution(
589
+ session: AsyncSession, repository_id: uuid.UUID, *, limit: int = 15
590
+ ) -> list[dict]:
591
+ """按子系统统计。未归类的不单列 —— 那是缺失信息,不是一类子系统。"""
592
+ stmt = (
593
+ select(Classification.subsystem, func.count())
594
+ .where(
595
+ Classification.repository_id == repository_id,
596
+ Classification.subsystem.is_not(None),
597
+ )
598
+ .group_by(Classification.subsystem)
599
+ .order_by(func.count().desc())
600
+ .limit(limit)
601
+ )
602
+ return [
603
+ {"subsystem": row[0], "label": domain.subsystem_label(row[0]), "count": row[1]}
604
+ for row in (await session.execute(stmt)).all()
605
+ ]
606
+
607
+
608
+ async def pulls_missing_classification(
609
+ session: AsyncSession, repository_id: uuid.UUID, *, limit: int
610
+ ) -> list[PullRequest]:
611
+ """挑出尚未分类、或规则判为 unknown 的活动 PR,供 AI 补判。
612
+
613
+ 已合并/已关闭的不再补判 —— 对它们做分类不产生任何可执行动作。
614
+ """
615
+ classified = select(Classification.subject_id).where(
616
+ Classification.subject_type == SubjectType.PULL_REQUEST,
617
+ Classification.kind != PRKind.UNKNOWN,
618
+ )
619
+ stmt = (
620
+ select(PullRequest)
621
+ .where(
622
+ PullRequest.repository_id == repository_id,
623
+ PullRequest.state == "open",
624
+ PullRequest.id.not_in(classified),
625
+ )
626
+ .order_by(PullRequest.atomgit_updated_at.desc())
627
+ .limit(limit)
628
+ )
629
+ return list((await session.scalars(stmt)).all())
630
+
631
+
632
+ async def issues_missing_classification(
633
+ session: AsyncSession, repository_id: uuid.UUID, *, limit: int
634
+ ) -> list[Issue]:
635
+ """挑出尚未分类、或规则判为 unknown 的活动 Issue,供 AI 补判。
636
+
637
+ 与 PR 那条同理,但 Issue 的未判定比例高得多:Issue 没有变更文件,
638
+ 子系统与类别只能从标题与正文推断,规则能命中的比例因此低很多
639
+ (实测 2039 条 Issue 里 945 条未判定,占 46%)。
640
+
641
+ Issue 数量大(2000+),因此调用方要限量、低频地跑 ——
642
+ 一轮几十条,慢慢补,不需要也不应该一次性判定完。
643
+ """
644
+ classified = select(Classification.subject_id).where(
645
+ Classification.subject_type == SubjectType.ISSUE,
646
+ Classification.kind != PRKind.UNKNOWN,
647
+ )
648
+ stmt = (
649
+ select(Issue)
650
+ .where(
651
+ Issue.repository_id == repository_id,
652
+ Issue.state == "open",
653
+ Issue.id.not_in(classified),
654
+ )
655
+ .order_by(Issue.atomgit_updated_at.desc())
656
+ .limit(limit)
657
+ )
658
+ return list((await session.scalars(stmt)).all())