@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,42 @@
1
+ """请求上下文:注入 request_id,绑定日志上下文,回写耗时头。"""
2
+
3
+ import time
4
+ import uuid
5
+
6
+ import structlog
7
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
8
+ from starlette.requests import Request
9
+ from starlette.responses import Response
10
+
11
+ REQUEST_ID_HEADER = "X-Request-ID"
12
+
13
+
14
+ class RequestContextMiddleware(BaseHTTPMiddleware):
15
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
16
+ request_id = request.headers.get(REQUEST_ID_HEADER) or uuid.uuid4().hex
17
+ request.state.request_id = request_id
18
+
19
+ structlog.contextvars.clear_contextvars()
20
+ structlog.contextvars.bind_contextvars(
21
+ request_id=request_id,
22
+ method=request.method,
23
+ path=request.url.path,
24
+ )
25
+
26
+ started = time.perf_counter()
27
+ try:
28
+ response = await call_next(request)
29
+ finally:
30
+ structlog.contextvars.unbind_contextvars("method", "path")
31
+
32
+ duration_ms = (time.perf_counter() - started) * 1000
33
+ response.headers[REQUEST_ID_HEADER] = request_id
34
+ response.headers["X-Response-Time-MS"] = f"{duration_ms:.1f}"
35
+
36
+ structlog.get_logger(__name__).info(
37
+ "http_request",
38
+ status_code=response.status_code,
39
+ duration_ms=round(duration_ms, 1),
40
+ )
41
+ structlog.contextvars.clear_contextvars()
42
+ return response
@@ -0,0 +1,112 @@
1
+ """ORM 模型集中导出。
2
+
3
+ Alembic 的 autogenerate 依赖此模块导入全部模型,否则新表不会被检测到。
4
+ """
5
+
6
+ from app.models.ai import (
7
+ AIAnalysis,
8
+ AIProviderKind,
9
+ AITask,
10
+ AnalysisStatus,
11
+ LLMProvider,
12
+ PromptTemplate,
13
+ TaskRouting,
14
+ )
15
+ from app.models.attention import AttentionItem, AttentionRule, AttentionSeverity
16
+ from app.models.attention_settings import AttentionRuleSetting
17
+ from app.models.audit import AuditLog, AuditResult
18
+ from app.models.base import Base, TimestampMixin
19
+ from app.models.classification import (
20
+ Classification,
21
+ ClassificationSource,
22
+ LabelSyncState,
23
+ PRKind,
24
+ SubjectType,
25
+ )
26
+ from app.models.credential import Credential, CredentialKind
27
+ from app.models.issue import Issue, IssueLinkSource, IssueType, PRIssueLink
28
+ from app.models.meeting import (
29
+ Meeting,
30
+ MeetingAgenda,
31
+ MeetingStatus,
32
+ ReleaseReport,
33
+ ReleaseReportPatch,
34
+ )
35
+ from app.models.pull_request import (
36
+ PRComment,
37
+ PRCommit,
38
+ PRFile,
39
+ PRState,
40
+ PullRequest,
41
+ ReviewEvent,
42
+ ReviewEventSource,
43
+ ReviewEventType,
44
+ )
45
+ from app.models.repository import (
46
+ DEFAULT_API_BASE_URL,
47
+ Repository,
48
+ SyncCursor,
49
+ SyncResource,
50
+ SyncRun,
51
+ SyncStatus,
52
+ SyncTrigger,
53
+ WebhookDelivery,
54
+ )
55
+ from app.models.sig import MemberRole, MemberSource, ReleaseBranch, SIGMember, SubsystemOwner
56
+ from app.models.user import User
57
+
58
+ __all__ = [
59
+ "DEFAULT_API_BASE_URL",
60
+ "AIAnalysis",
61
+ "AIProviderKind",
62
+ "AITask",
63
+ "AnalysisStatus",
64
+ "AttentionItem",
65
+ "AttentionRule",
66
+ "AttentionRuleSetting",
67
+ "AttentionSeverity",
68
+ "AuditLog",
69
+ "AuditResult",
70
+ "Base",
71
+ "Classification",
72
+ "ClassificationSource",
73
+ "Credential",
74
+ "CredentialKind",
75
+ "Issue",
76
+ "IssueLinkSource",
77
+ "IssueType",
78
+ "LLMProvider",
79
+ "LabelSyncState",
80
+ "Meeting",
81
+ "MeetingAgenda",
82
+ "MeetingStatus",
83
+ "MemberRole",
84
+ "MemberSource",
85
+ "PRComment",
86
+ "PRCommit",
87
+ "PRFile",
88
+ "PRIssueLink",
89
+ "PRKind",
90
+ "PRState",
91
+ "PromptTemplate",
92
+ "PullRequest",
93
+ "ReleaseBranch",
94
+ "ReleaseReport",
95
+ "ReleaseReportPatch",
96
+ "Repository",
97
+ "ReviewEvent",
98
+ "ReviewEventSource",
99
+ "ReviewEventType",
100
+ "SIGMember",
101
+ "SubjectType",
102
+ "SubsystemOwner",
103
+ "SyncCursor",
104
+ "SyncResource",
105
+ "SyncRun",
106
+ "SyncStatus",
107
+ "SyncTrigger",
108
+ "TaskRouting",
109
+ "TimestampMixin",
110
+ "User",
111
+ "WebhookDelivery",
112
+ ]
@@ -0,0 +1,214 @@
1
+ """AI 相关模型:模型配置、任务路由、Prompt 模板、分析结果。"""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+
7
+ from sqlalchemy import (
8
+ Boolean,
9
+ DateTime,
10
+ Enum,
11
+ Float,
12
+ ForeignKey,
13
+ Integer,
14
+ String,
15
+ Text,
16
+ UniqueConstraint,
17
+ func,
18
+ )
19
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
20
+ from sqlalchemy.orm import Mapped, mapped_column
21
+
22
+ from app.models.base import Base, TimestampMixin
23
+
24
+
25
+ class AIProviderKind(StrEnum):
26
+ """目前统一走 OpenAI 兼容协议,保留字段以便后续接入其他协议。"""
27
+
28
+ OPENAI_COMPATIBLE = "openai_compatible"
29
+
30
+
31
+ class AITask(StrEnum):
32
+ """分析任务类型。取值同时用于任务路由配置的键。"""
33
+
34
+ CLASSIFY = "classify"
35
+ SUMMARIZE = "summarize"
36
+ RISK_REVIEW = "risk_review"
37
+ BACKPORT_VERIFY = "backport_verify"
38
+
39
+
40
+ class AnalysisStatus(StrEnum):
41
+ PENDING = "pending"
42
+ RUNNING = "running"
43
+ SUCCEEDED = "succeeded"
44
+ FAILED = "failed"
45
+
46
+
47
+ class LLMProvider(Base, TimestampMixin):
48
+ """一个可用的模型端点。
49
+
50
+ 按任务配置不同档位是刻意的:分类这类轻任务用便宜模型,
51
+ 代码风险审查才用强模型。全部走大模型会让成本失控。
52
+ """
53
+
54
+ __tablename__ = "ksp_llm_provider"
55
+
56
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
57
+ name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
58
+ kind: Mapped[AIProviderKind] = mapped_column(
59
+ Enum(
60
+ AIProviderKind,
61
+ name="ai_provider_kind",
62
+ values_callable=lambda e: [m.value for m in e],
63
+ ),
64
+ nullable=False,
65
+ default=AIProviderKind.OPENAI_COMPATIBLE,
66
+ )
67
+
68
+ base_url: Mapped[str] = mapped_column(String(255), nullable=False)
69
+ # 指向凭据表,明文只在调用时解密
70
+ credential_id: Mapped[uuid.UUID | None] = mapped_column(
71
+ UUID(as_uuid=True), ForeignKey("ksp_credential.id", ondelete="SET NULL"), nullable=True
72
+ )
73
+ model: Mapped[str] = mapped_column(String(128), nullable=False)
74
+
75
+ temperature: Mapped[float] = mapped_column(Float, nullable=False, default=0.1)
76
+ max_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=4096)
77
+ timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=180)
78
+
79
+ enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
80
+ is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
81
+
82
+ last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
83
+ last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
84
+
85
+ def __repr__(self) -> str:
86
+ return f"<LLMProvider {self.name} model={self.model}>"
87
+
88
+
89
+ class TaskRouting(Base, TimestampMixin):
90
+ """任务 → 模型的路由。
91
+
92
+ 没有为某任务配置时回落到 is_default 的 provider,
93
+ 保证"刚部署还没配路由"时系统仍能工作。
94
+ """
95
+
96
+ __tablename__ = "ksp_task_routing"
97
+
98
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
99
+ task: Mapped[AITask] = mapped_column(
100
+ Enum(AITask, name="ai_task", values_callable=lambda e: [m.value for m in e]),
101
+ nullable=False,
102
+ unique=True,
103
+ )
104
+ provider_id: Mapped[uuid.UUID] = mapped_column(
105
+ UUID(as_uuid=True),
106
+ ForeignKey("ksp_llm_provider.id", ondelete="CASCADE"),
107
+ nullable=False,
108
+ )
109
+ fallback_provider_id: Mapped[uuid.UUID | None] = mapped_column(
110
+ UUID(as_uuid=True), ForeignKey("ksp_llm_provider.id", ondelete="SET NULL"), nullable=True
111
+ )
112
+ system_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
113
+ enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
114
+
115
+
116
+ class AIAnalysis(Base):
117
+ """一次分析的结果。
118
+
119
+ input_hash 用于幂等:内容没变就不重复调用模型 ——
120
+ AI 调用是这套系统里唯一按次计费的部分。
121
+ """
122
+
123
+ __tablename__ = "ksp_ai_analysis"
124
+ __table_args__ = (
125
+ UniqueConstraint(
126
+ "subject_type", "subject_id", "task", name="uq_ksp_ai_analysis_subject_task"
127
+ ),
128
+ )
129
+
130
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
131
+ repository_id: Mapped[uuid.UUID] = mapped_column(
132
+ UUID(as_uuid=True),
133
+ ForeignKey("ksp_repository.id", ondelete="CASCADE"),
134
+ nullable=False,
135
+ index=True,
136
+ )
137
+ subject_type: Mapped[str] = mapped_column(String(32), nullable=False)
138
+ subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
139
+
140
+ task: Mapped[AITask] = mapped_column(
141
+ Enum(AITask, name="ai_task", values_callable=lambda e: [m.value for m in e]),
142
+ nullable=False,
143
+ index=True,
144
+ )
145
+ status: Mapped[AnalysisStatus] = mapped_column(
146
+ Enum(
147
+ AnalysisStatus,
148
+ name="ai_analysis_status",
149
+ values_callable=lambda e: [m.value for m in e],
150
+ ),
151
+ nullable=False,
152
+ default=AnalysisStatus.PENDING,
153
+ index=True,
154
+ )
155
+
156
+ provider_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
157
+ model: Mapped[str | None] = mapped_column(String(128), nullable=True)
158
+
159
+ # 输入指纹:内容未变则跳过重复分析
160
+ input_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
161
+ prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
162
+ completion_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
163
+
164
+ result: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
165
+ # 原始响应保留供审计与调试,排查"模型到底说了什么"时是唯一依据
166
+ raw_response: Mapped[str | None] = mapped_column(Text, nullable=True)
167
+ error: Mapped[str | None] = mapped_column(Text, nullable=True)
168
+
169
+ triggered_by: Mapped[uuid.UUID | None] = mapped_column(
170
+ UUID(as_uuid=True), ForeignKey("ksp_user.id", ondelete="SET NULL"), nullable=True
171
+ )
172
+ started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
173
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
174
+ created_at: Mapped[datetime] = mapped_column(
175
+ DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
176
+ )
177
+
178
+ @property
179
+ def duration_seconds(self) -> float | None:
180
+ if self.started_at is None or self.finished_at is None:
181
+ return None
182
+ return (self.finished_at - self.started_at).total_seconds()
183
+
184
+ @property
185
+ def total_tokens(self) -> int:
186
+ return self.prompt_tokens + self.completion_tokens
187
+
188
+
189
+ class PromptTemplate(Base, TimestampMixin):
190
+ """领域 Prompt 模板。
191
+
192
+ 版本化以支持"同一分析用不同 Prompt 重跑对比"。
193
+ 内置模板从代码加载,此处保存的是人工调整后的覆盖版本。
194
+ """
195
+
196
+ __tablename__ = "ksp_prompt_template"
197
+ __table_args__ = (UniqueConstraint("task", "version", name="uq_ksp_prompt_task_version"),)
198
+
199
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
200
+ task: Mapped[AITask] = mapped_column(
201
+ Enum(AITask, name="ai_task", values_callable=lambda e: [m.value for m in e]),
202
+ nullable=False,
203
+ index=True,
204
+ )
205
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
206
+ version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
207
+
208
+ system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
209
+ user_template: Mapped[str] = mapped_column(Text, nullable=False)
210
+
211
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
212
+ created_by: Mapped[uuid.UUID | None] = mapped_column(
213
+ UUID(as_uuid=True), ForeignKey("ksp_user.id", ondelete="SET NULL"), nullable=True
214
+ )
@@ -0,0 +1,131 @@
1
+ """关注项。
2
+
3
+ 跟踪子系统的输出:把散落在各个 PR/Issue 上的问题收敛成一份可认领的待办。
4
+ 维护者不需要逐条翻 PR 才能发现"这个已经两周没人看了"。
5
+ """
6
+
7
+ import uuid
8
+ from datetime import datetime
9
+ from enum import StrEnum
10
+
11
+ from sqlalchemy import (
12
+ BigInteger,
13
+ DateTime,
14
+ Enum,
15
+ ForeignKey,
16
+ Index,
17
+ String,
18
+ Text,
19
+ UniqueConstraint,
20
+ func,
21
+ )
22
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
23
+ from sqlalchemy.orm import Mapped, mapped_column
24
+
25
+ from app.models.base import Base
26
+
27
+
28
+ class AttentionSeverity(StrEnum):
29
+ BLOCKER = "blocker" # 必须处理才能推进
30
+ CRITICAL = "critical"
31
+ WARNING = "warning"
32
+ INFO = "info"
33
+
34
+
35
+ class AttentionRule(StrEnum):
36
+ """内置规则。
37
+
38
+ 注意:规则的权威定义在领域层(``app.domain.attention.Rule``),
39
+ 此处保持一致 —— 同一概念两处各写一份必然漂移,
40
+ 表现为"规则算出的值写不进数据库"。
41
+ """
42
+
43
+ STALE = "stale"
44
+ REVIEW_SLA_BREACH = "review_sla_breach"
45
+ CI_FAILED = "ci_failed"
46
+ MERGE_CONFLICT = "merge_conflict"
47
+ NEEDS_ISSUE = "needs_issue"
48
+ CLA_DENIED = "cla_denied"
49
+ CLA_PENDING_LONG = "cla_pending_long"
50
+ REJECTED = "rejected"
51
+ BINARY_FILE = "binary_file"
52
+ MISSING_SIGNED_OFF = "missing_signed_off"
53
+ CVE_AGING = "cve_aging"
54
+ ISSUE_UNASSIGNED = "issue_unassigned"
55
+ ISSUE_STALE = "issue_stale"
56
+
57
+
58
+ class AttentionItem(Base):
59
+ """一条待处理事项。
60
+
61
+ 生命周期:每次 compute_attention 重新求值 ——
62
+ 未解决的保留 first_detected_at 并刷新 detail;
63
+ 已解决的置 resolved_at;已认领的不再重复提示。
64
+ """
65
+
66
+ __tablename__ = "ksp_attention_item"
67
+ __table_args__ = (
68
+ UniqueConstraint(
69
+ "subject_type", "subject_id", "rule", name="uq_ksp_attention_subject_rule"
70
+ ),
71
+ Index("ix_ksp_attention_open", "resolved_at", "severity"),
72
+ )
73
+
74
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
75
+ repository_id: Mapped[uuid.UUID] = mapped_column(
76
+ UUID(as_uuid=True),
77
+ ForeignKey("ksp_repository.id", ondelete="CASCADE"),
78
+ nullable=False,
79
+ index=True,
80
+ )
81
+
82
+ subject_type: Mapped[str] = mapped_column(String(32), nullable=False)
83
+ subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
84
+ # 冗余一份编号与标题,让关注队列无需联表即可渲染
85
+ subject_number: Mapped[int] = mapped_column(BigInteger, nullable=False)
86
+ subject_title: Mapped[str] = mapped_column(Text, nullable=False, default="")
87
+ subject_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
88
+
89
+ rule: Mapped[AttentionRule] = mapped_column(
90
+ Enum(
91
+ AttentionRule,
92
+ name="attention_rule",
93
+ values_callable=lambda e: [m.value for m in e],
94
+ ),
95
+ nullable=False,
96
+ index=True,
97
+ )
98
+ severity: Mapped[AttentionSeverity] = mapped_column(
99
+ Enum(
100
+ AttentionSeverity,
101
+ name="attention_severity",
102
+ values_callable=lambda e: [m.value for m in e],
103
+ ),
104
+ nullable=False,
105
+ index=True,
106
+ )
107
+
108
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
109
+ detail: Mapped[str | None] = mapped_column(Text, nullable=True)
110
+ # 规则求值时的原始数据,便于解释"为什么判定为超期"
111
+ evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
112
+
113
+ first_detected_at: Mapped[datetime] = mapped_column(
114
+ DateTime(timezone=True), server_default=func.now(), nullable=False
115
+ )
116
+ last_evaluated_at: Mapped[datetime] = mapped_column(
117
+ DateTime(timezone=True), server_default=func.now(), nullable=False
118
+ )
119
+ resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
120
+
121
+ acknowledged_by: Mapped[uuid.UUID | None] = mapped_column(
122
+ UUID(as_uuid=True), ForeignKey("ksp_user.id", ondelete="SET NULL"), nullable=True
123
+ )
124
+ acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
125
+
126
+ @property
127
+ def is_open(self) -> bool:
128
+ return self.resolved_at is None
129
+
130
+ def __repr__(self) -> str:
131
+ return f"<AttentionItem {self.rule.value} #{self.subject_number}>"
@@ -0,0 +1,61 @@
1
+ """关注项的订阅设置。
2
+
3
+ 背景:规则引擎对**每个命中对象**产出一条关注项。548 个未推进的 CVE PR 就是
4
+ 463 条 cve_aging,3000 个陈旧 Issue 就是 3000 条 issue_stale —— 加起来四千多
5
+ 条。那不是"需要关注",那是"全部清单"。使用者翻两页就会放弃这个功能。
6
+
7
+ 两件事分开解决:
8
+
9
+ 1. **订阅**(本表):哪些规则要提醒、阈值多少、只看 PR 还是只看 Issue。
10
+ 团队节奏不同,阈值不该写死在代码里 —— 写死的结果是没人用,
11
+ 因为第一条不符合自己节奏的提醒就会让人关掉整个队列。
12
+
13
+ 2. **收敛**(见 attention_service):同一规则下的条目按主题聚合成分组,
14
+ 队列显示分组,展开才是条目。分组是把"463 条"变成"3 类问题"的地方。
15
+
16
+ 设置是全局的而非按用户:这是 SIG 的公共队列,不是个人待办。
17
+ 按用户存会让"这个 PR 到底有没有人管"变得无法回答。
18
+ """
19
+
20
+ import uuid
21
+ from datetime import datetime
22
+
23
+ from sqlalchemy import (
24
+ Boolean,
25
+ DateTime,
26
+ Integer,
27
+ String,
28
+ Text,
29
+ UniqueConstraint,
30
+ )
31
+ from sqlalchemy.dialects.postgresql import UUID
32
+ from sqlalchemy.orm import Mapped, mapped_column
33
+
34
+ from app.models.base import Base, TimestampMixin
35
+
36
+
37
+ class AttentionRuleSetting(Base, TimestampMixin):
38
+ """单条规则的订阅设置。规则取值的权威定义在 ``domain.attention.Rule``。"""
39
+
40
+ __tablename__ = "ksp_attention_rule_setting"
41
+ __table_args__ = (UniqueConstraint("rule", name="uq_ksp_attention_rule_setting_rule"),)
42
+
43
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
44
+
45
+ rule: Mapped[str] = mapped_column(String(32), nullable=False)
46
+ enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
47
+
48
+ days: Mapped[int | None] = mapped_column(Integer, nullable=True)
49
+ """阈值天数。为空表示沿用领域层的默认值。"""
50
+
51
+ subject_scope: Mapped[str] = mapped_column(String(16), nullable=False, default="all")
52
+ """``all`` / ``pull_request`` / ``issue``。有些团队只关心 PR。"""
53
+
54
+ note: Mapped[str | None] = mapped_column(Text, nullable=True)
55
+
56
+ updated_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
57
+ effective_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
58
+ """上次生效时间。界面据此说明"设置从下次重算开始生效"。"""
59
+
60
+ def __repr__(self) -> str:
61
+ return f"<AttentionRuleSetting {self.rule} enabled={self.enabled}>"
@@ -0,0 +1,53 @@
1
+ """审计日志。仅追加,不修改不删除。"""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+
7
+ from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, String, Text, func
8
+ from sqlalchemy.dialects.postgresql import INET, JSONB, UUID
9
+ from sqlalchemy.orm import Mapped, mapped_column
10
+
11
+ from app.models.base import Base
12
+
13
+
14
+ class AuditResult(StrEnum):
15
+ SUCCESS = "success"
16
+ FAILURE = "failure"
17
+
18
+
19
+ class AuditLog(Base):
20
+ __tablename__ = "ksp_audit_log"
21
+
22
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
23
+
24
+ actor_user_id: Mapped[uuid.UUID | None] = mapped_column(
25
+ UUID(as_uuid=True),
26
+ ForeignKey("ksp_user.id", ondelete="SET NULL"),
27
+ nullable=True,
28
+ index=True,
29
+ )
30
+ actor_username: Mapped[str | None] = mapped_column(String(64), nullable=True)
31
+ actor_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
32
+
33
+ action: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
34
+ target_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
35
+ target_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
36
+
37
+ before: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
38
+ after: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
39
+
40
+ result: Mapped[AuditResult] = mapped_column(
41
+ Enum(AuditResult, name="audit_result", values_callable=lambda e: [m.value for m in e]),
42
+ nullable=False,
43
+ default=AuditResult.SUCCESS,
44
+ )
45
+ error: Mapped[str | None] = mapped_column(Text, nullable=True)
46
+ request_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
47
+
48
+ created_at: Mapped[datetime] = mapped_column(
49
+ DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
50
+ )
51
+
52
+ def __repr__(self) -> str:
53
+ return f"<AuditLog {self.action} by={self.actor_username} result={self.result.value}>"
@@ -0,0 +1,35 @@
1
+ """SQLAlchemy 声明式基类与混入。"""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from sqlalchemy import DateTime, MetaData, func
6
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
7
+
8
+ # 统一约束命名,保证 Alembic 生成的迁移可读且可回滚
9
+ NAMING_CONVENTION = {
10
+ "ix": "ix_%(column_0_label)s",
11
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
12
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
13
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
14
+ "pk": "pk_%(table_name)s",
15
+ }
16
+
17
+
18
+ class Base(DeclarativeBase):
19
+ metadata = MetaData(naming_convention=NAMING_CONVENTION)
20
+
21
+
22
+ class TimestampMixin:
23
+ """创建/更新时间戳。"""
24
+
25
+ created_at: Mapped[datetime] = mapped_column(
26
+ DateTime(timezone=True),
27
+ server_default=func.now(),
28
+ nullable=False,
29
+ )
30
+ updated_at: Mapped[datetime] = mapped_column(
31
+ DateTime(timezone=True),
32
+ server_default=func.now(),
33
+ onupdate=lambda: datetime.now(UTC),
34
+ nullable=False,
35
+ )