Skip to content

Commit b95f515

Browse files
MKY508claude
andcommitted
feat: 实现语义层 Level 1 (术语字典)
- 新增 SemanticTerm 数据库模型,支持指标/维度/筛选/别名四种类型 - 新增语义层 API 路由 (CRUD /api/v1/config/semantic/terms) - 修改 ExecutionService 自动注入语义上下文到 AI 提示 - 新增前端 SemanticSettings 组件,支持术语管理 - 更新 README 添加语义层特性说明 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fe28c9e commit b95f515

File tree

10 files changed

+740
-13
lines changed

10 files changed

+740
-13
lines changed

README.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,25 @@ v2 是完全重构版本,前后端分离架构:
3333

3434
## 功能
3535

36-
- 自然语言查询数据库,支持中文业务术语
37-
- SSE 流式响应,实时显示思考过程,多轮问答
38-
- Plotly嵌入前端数据可视化
39-
- 多用户、多模型、多数据库支持
40-
- 内置 SQLite **示例数据库**,开箱即用
36+
- 🗣️ **自然语言查询** - 用中文描述需求,AI 自动生成 SQL
37+
- 📊 **语义层** - 定义业务术语(如"月活用户"、"GMV"),AI 自动理解并转换为 SQL 表达式
38+
-**SSE 流式响应** - 实时显示思考过程,支持多轮问答
39+
- 📈 **数据可视化** - Plotly 图表嵌入前端展示
40+
- 👥 **多租户** - 多用户、多模型、多数据库支持
41+
- 🎯 **开箱即用** - 内置 SQLite 示例数据库
42+
43+
### 语义层特性
44+
45+
语义层让 AI 更懂你的业务数据:
46+
47+
| 术语类型 | 说明 | 示例 |
48+
|---------|------|------|
49+
| **指标** | 可计算的数值 | 月活用户 → `COUNT(DISTINCT user_id)` |
50+
| **维度** | 分组依据 | 地区 → `region` |
51+
| **筛选条件** | 常用过滤 | 活跃用户 → `last_active >= DATE_SUB(NOW(), 30)` |
52+
| **别名** | 表/字段映射 | 订单表 → `orders` |
53+
54+
在设置页面的"语义层"标签中配置术语,AI 生成 SQL 时会自动参考这些定义。
4155

4256
---
4357

apps/api/app/api/v1/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from fastapi import APIRouter
44

5-
from app.api.v1 import auth, chat, connections, history, models, user_config
5+
from app.api.v1 import auth, chat, connections, history, models, semantic, user_config
66

77
api_router = APIRouter()
88

@@ -12,4 +12,5 @@
1212
api_router.include_router(history.router, prefix="/conversations", tags=["历史记录"])
1313
api_router.include_router(models.router, prefix="/config", tags=["模型配置"])
1414
api_router.include_router(connections.router, prefix="/config", tags=["数据库连接"])
15+
api_router.include_router(semantic.router, prefix="/config", tags=["语义层"])
1516
api_router.include_router(user_config.router, tags=["用户配置"])

apps/api/app/api/v1/semantic.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""语义层管理 API"""
2+
3+
from uuid import UUID
4+
5+
from fastapi import APIRouter, Depends, HTTPException, status
6+
from sqlalchemy import select
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from app.api.deps import get_current_user
10+
from app.db import get_db
11+
from app.db.tables import SemanticTerm, User
12+
from app.models import (
13+
APIResponse,
14+
SemanticTermCreate,
15+
SemanticTermResponse,
16+
SemanticTermUpdate,
17+
)
18+
19+
router = APIRouter(prefix="/semantic/terms", tags=["semantic"])
20+
21+
22+
@router.get("", response_model=APIResponse[list[SemanticTermResponse]])
23+
async def list_terms(
24+
connection_id: UUID | None = None,
25+
current_user: User = Depends(get_current_user),
26+
db: AsyncSession = Depends(get_db),
27+
):
28+
"""获取语义术语列表"""
29+
query = select(SemanticTerm).where(
30+
SemanticTerm.user_id == current_user.id,
31+
SemanticTerm.is_active.is_(True),
32+
)
33+
if connection_id:
34+
query = query.where(SemanticTerm.connection_id == connection_id)
35+
36+
query = query.order_by(SemanticTerm.term)
37+
result = await db.execute(query)
38+
terms = result.scalars().all()
39+
40+
return APIResponse.ok(data=[SemanticTermResponse.model_validate(t) for t in terms])
41+
42+
43+
@router.post("", response_model=APIResponse[SemanticTermResponse])
44+
async def create_term(
45+
term_in: SemanticTermCreate,
46+
current_user: User = Depends(get_current_user),
47+
db: AsyncSession = Depends(get_db),
48+
):
49+
"""创建语义术语"""
50+
# 检查术语是否已存在
51+
existing = await db.execute(
52+
select(SemanticTerm).where(
53+
SemanticTerm.user_id == current_user.id,
54+
SemanticTerm.term == term_in.term,
55+
SemanticTerm.connection_id == term_in.connection_id,
56+
)
57+
)
58+
if existing.scalar_one_or_none():
59+
raise HTTPException(
60+
status_code=status.HTTP_400_BAD_REQUEST,
61+
detail=f"术语 '{term_in.term}' 已存在",
62+
)
63+
64+
term = SemanticTerm(
65+
user_id=current_user.id,
66+
term=term_in.term,
67+
expression=term_in.expression,
68+
term_type=term_in.term_type,
69+
connection_id=term_in.connection_id,
70+
description=term_in.description,
71+
examples=term_in.examples,
72+
)
73+
db.add(term)
74+
await db.commit()
75+
await db.refresh(term)
76+
77+
return APIResponse.ok(data=SemanticTermResponse.model_validate(term), message="术语已创建")
78+
79+
80+
@router.get("/{term_id}", response_model=APIResponse[SemanticTermResponse])
81+
async def get_term(
82+
term_id: UUID,
83+
current_user: User = Depends(get_current_user),
84+
db: AsyncSession = Depends(get_db),
85+
):
86+
"""获取单个语义术语"""
87+
result = await db.execute(
88+
select(SemanticTerm).where(
89+
SemanticTerm.id == term_id,
90+
SemanticTerm.user_id == current_user.id,
91+
)
92+
)
93+
term = result.scalar_one_or_none()
94+
95+
if not term:
96+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="术语不存在")
97+
98+
return APIResponse.ok(data=SemanticTermResponse.model_validate(term))
99+
100+
101+
@router.put("/{term_id}", response_model=APIResponse[SemanticTermResponse])
102+
async def update_term(
103+
term_id: UUID,
104+
term_in: SemanticTermUpdate,
105+
current_user: User = Depends(get_current_user),
106+
db: AsyncSession = Depends(get_db),
107+
):
108+
"""更新语义术语"""
109+
result = await db.execute(
110+
select(SemanticTerm).where(
111+
SemanticTerm.id == term_id,
112+
SemanticTerm.user_id == current_user.id,
113+
)
114+
)
115+
term = result.scalar_one_or_none()
116+
117+
if not term:
118+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="术语不存在")
119+
120+
# 更新字段
121+
update_data = term_in.model_dump(exclude_unset=True)
122+
for field, value in update_data.items():
123+
setattr(term, field, value)
124+
125+
await db.commit()
126+
await db.refresh(term)
127+
128+
return APIResponse.ok(data=SemanticTermResponse.model_validate(term), message="术语已更新")
129+
130+
131+
@router.delete("/{term_id}", response_model=APIResponse[dict])
132+
async def delete_term(
133+
term_id: UUID,
134+
current_user: User = Depends(get_current_user),
135+
db: AsyncSession = Depends(get_db),
136+
):
137+
"""删除语义术语"""
138+
result = await db.execute(
139+
select(SemanticTerm).where(
140+
SemanticTerm.id == term_id,
141+
SemanticTerm.user_id == current_user.id,
142+
)
143+
)
144+
term = result.scalar_one_or_none()
145+
146+
if not term:
147+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="术语不存在")
148+
149+
await db.delete(term)
150+
await db.commit()
151+
152+
return APIResponse.ok(message="术语已删除")

apps/api/app/db/tables.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class User(Base, UUIDMixin, TimestampMixin):
3434
conversations: Mapped[list["Conversation"]] = relationship(
3535
back_populates="user", cascade="all, delete-orphan"
3636
)
37+
semantic_terms: Mapped[list["SemanticTerm"]] = relationship(
38+
back_populates="user", cascade="all, delete-orphan"
39+
)
3740

3841

3942
class Connection(Base, UUIDMixin, TimestampMixin):
@@ -142,3 +145,28 @@ class RefreshToken(Base, UUIDMixin):
142145
expires_at: Mapped[datetime] = mapped_column(nullable=False)
143146
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
144147
revoked_at: Mapped[datetime | None] = mapped_column()
148+
149+
150+
class SemanticTerm(Base, UUIDMixin, TimestampMixin):
151+
"""语义术语表 - 业务术语字典"""
152+
153+
__tablename__ = "semantic_terms"
154+
155+
user_id: Mapped[UUID] = mapped_column(
156+
PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
157+
)
158+
connection_id: Mapped[UUID | None] = mapped_column(
159+
PG_UUID(as_uuid=True), ForeignKey("connections.id", ondelete="CASCADE")
160+
)
161+
term: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
162+
expression: Mapped[str] = mapped_column(Text, nullable=False)
163+
term_type: Mapped[str] = mapped_column(
164+
String(20), default="metric"
165+
) # metric, dimension, filter, alias
166+
description: Mapped[str | None] = mapped_column(Text)
167+
examples: Mapped[list[str]] = mapped_column(JSON, default=list)
168+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
169+
170+
# 关系
171+
user: Mapped["User"] = relationship(back_populates="semantic_terms")
172+
connection: Mapped["Connection | None"] = relationship()

apps/api/app/models/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@
3535
MessageCreate,
3636
MessageResponse,
3737
)
38+
from app.models.semantic import (
39+
SemanticContext,
40+
SemanticTermCreate,
41+
SemanticTermResponse,
42+
SemanticTermUpdate,
43+
)
3844

3945
__all__ = [
4046
# Auth
@@ -67,4 +73,9 @@
6773
"ConversationSummary",
6874
"MessageCreate",
6975
"MessageResponse",
76+
# Semantic
77+
"SemanticTermCreate",
78+
"SemanticTermUpdate",
79+
"SemanticTermResponse",
80+
"SemanticContext",
7081
]

apps/api/app/models/semantic.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""语义层相关模型"""
2+
3+
from datetime import datetime
4+
from typing import Literal
5+
from uuid import UUID
6+
7+
from pydantic import BaseModel, Field
8+
9+
10+
class SemanticTermCreate(BaseModel):
11+
"""创建语义术语"""
12+
13+
term: str = Field(..., min_length=1, max_length=100, description="术语名称")
14+
expression: str = Field(..., min_length=1, description="SQL 表达式或映射")
15+
term_type: Literal["metric", "dimension", "filter", "alias"] = Field(
16+
default="metric", description="术语类型"
17+
)
18+
connection_id: UUID | None = Field(default=None, description="关联的数据库连接")
19+
description: str | None = Field(default=None, description="术语描述")
20+
examples: list[str] = Field(default_factory=list, description="使用示例")
21+
22+
23+
class SemanticTermUpdate(BaseModel):
24+
"""更新语义术语"""
25+
26+
term: str | None = Field(default=None, max_length=100)
27+
expression: str | None = None
28+
term_type: Literal["metric", "dimension", "filter", "alias"] | None = None
29+
connection_id: UUID | None = None
30+
description: str | None = None
31+
examples: list[str] | None = None
32+
is_active: bool | None = None
33+
34+
35+
class SemanticTermResponse(BaseModel):
36+
"""语义术语响应"""
37+
38+
id: UUID
39+
term: str
40+
expression: str
41+
term_type: str
42+
connection_id: UUID | None = None
43+
description: str | None = None
44+
examples: list[str] = []
45+
is_active: bool = True
46+
created_at: datetime
47+
updated_at: datetime | None = None
48+
49+
model_config = {"from_attributes": True}
50+
51+
52+
class SemanticContext(BaseModel):
53+
"""语义上下文 - 用于注入到 AI 提示中"""
54+
55+
terms: list[SemanticTermResponse] = []
56+
57+
def to_prompt(self, language: str = "zh") -> str:
58+
"""生成提示文本"""
59+
if not self.terms:
60+
return ""
61+
62+
if language == "zh":
63+
lines = ["## 业务术语字典", "以下是用户定义的业务术语,请在生成 SQL 时参考:", ""]
64+
else:
65+
lines = ["## Business Term Dictionary", "Use these terms when generating SQL:", ""]
66+
67+
for term in self.terms:
68+
term_type_label = {
69+
"metric": "指标" if language == "zh" else "Metric",
70+
"dimension": "维度" if language == "zh" else "Dimension",
71+
"filter": "筛选条件" if language == "zh" else "Filter",
72+
"alias": "别名" if language == "zh" else "Alias",
73+
}.get(term.term_type, term.term_type)
74+
75+
lines.append(f"- **{term.term}** [{term_type_label}]: `{term.expression}`")
76+
if term.description:
77+
lines.append(f" {term.description}")
78+
if term.examples:
79+
examples_str = ", ".join(f'"{e}"' for e in term.examples[:3])
80+
lines.append(f" 示例: {examples_str}" if language == "zh" else f" Examples: {examples_str}")
81+
82+
return "\n".join(lines)

0 commit comments

Comments
 (0)