feat(AISVC-T6.9): 前后端接口对接修正及Dashboard/RAG Lab功能完善

## 后端修改
- 新增 Dashboard 统计 API (/admin/dashboard/stats)
- 新增知识库列表 API (/admin/kb/knowledge-bases),返回文档数量
- 会话列表 API 新增 tenantId 字段
- KBService 新增 list_knowledge_bases 方法

## 前端修改
- Dashboard 页面对接真实后端 API
- RAG Lab 知识库选择器显示文档数量
- Monitoring 页面修复数据映射
- 新增 dashboard.ts API 文件
- kb.ts 新增 listKnowledgeBases 函数
This commit is contained in:
MerCry 2026-02-24 19:52:52 +08:00
parent 8731beaeb5
commit 5148c6ef42
12 changed files with 443 additions and 142 deletions

View File

@ -0,0 +1,11 @@
import request from '@/utils/request'
/**
* Dashboard
*/
export function getDashboardStats() {
return request({
url: '/admin/dashboard/stats',
method: 'get'
})
}

View File

@ -1,5 +1,15 @@
import request from '@/utils/request'
/**
*
*/
export function listKnowledgeBases() {
return request({
url: '/admin/kb/knowledge-bases',
method: 'get'
})
}
/**
* [AC-ASA-08]
*/
@ -31,3 +41,13 @@ export function getIndexJob(jobId: string) {
method: 'get'
})
}
/**
* [AC-ASA-08]
*/
export function deleteDocument(docId: string) {
return request({
url: `/admin/kb/documents/${docId}`,
method: 'delete'
})
}

View File

@ -1,34 +1,66 @@
<template>
<div class="dashboard-container">
<el-row :gutter="20">
<el-row :gutter="20" v-loading="loading">
<el-col :span="6">
<el-card shadow="hover">
<template #header>知识库总数</template>
<div class="card-content">12</div>
<div class="card-content">{{ stats.knowledgeBases }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>文档总数</template>
<div class="card-content">{{ stats.totalDocuments }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>总消息数</template>
<div class="card-content">1,284</div>
<div class="card-content">{{ stats.totalMessages.toLocaleString() }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>平均响应时间</template>
<div class="card-content">1.2s</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>活跃租户</template>
<div class="card-content">5</div>
<template #header>会话总数</template>
<div class="card-content">{{ stats.totalSessions }}</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { getDashboardStats } from '@/api/dashboard'
const loading = ref(false)
const stats = reactive({
knowledgeBases: 0,
totalDocuments: 0,
totalMessages: 0,
totalSessions: 0
})
const fetchStats = async () => {
loading.value = true
try {
const res: any = await getDashboardStats()
stats.knowledgeBases = res.knowledgeBases || 0
stats.totalDocuments = res.totalDocuments || 0
stats.totalMessages = res.totalMessages || 0
stats.totalSessions = res.totalSessions || 0
} catch (error) {
console.error('Failed to fetch dashboard stats:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchStats()
})
</script>
<style scoped>
.dashboard-container {
padding: 20px;

View File

@ -21,7 +21,7 @@
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button link type="primary" @click="handleViewJob(scope.row)">查看详情</el-button>
<el-button link type="danger">删除</el-button>
<el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -54,10 +54,11 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { uploadDocument, listDocuments, getIndexJob } from '@/api/kb'
import { ElMessage, ElMessageBox } from 'element-plus'
import { uploadDocument, listDocuments, getIndexJob, deleteDocument } from '@/api/kb'
interface DocumentItem {
docId: string
name: string
status: string
jobId: string
@ -95,9 +96,10 @@ const fetchDocuments = async () => {
try {
const res = await listDocuments({})
tableData.value = res.data.map((doc: any) => ({
docId: doc.docId,
name: doc.fileName,
status: doc.status,
jobId: doc.docId,
jobId: doc.jobId,
createTime: new Date(doc.createdAt).toLocaleString('zh-CN')
}))
} catch (error) {
@ -124,6 +126,24 @@ const handleViewJob = async (row: DocumentItem) => {
jobDialogVisible.value = true
}
const handleDelete = async (row: DocumentItem) => {
try {
await ElMessageBox.confirm(`确定要删除文档 "${row.name}" 吗?`, '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await deleteDocument(row.docId)
ElMessage.success('文档删除成功')
fetchDocuments()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除失败')
}
}
}
const refreshJobStatus = async () => {
if (currentJob.value?.jobId) {
currentJob.value = await fetchJobStatus(currentJob.value.jobId)

View File

@ -22,7 +22,6 @@
</div>
</template>
<!-- 使用 BaseTable 展示会话列表 [AC-ASA-09] -->
<base-table
:data="tableData"
:total="total"
@ -31,8 +30,8 @@
@pagination="getList"
v-loading="loading"
>
<el-table-column prop="sessionId" label="会话 ID" width="180" show-overflow-tooltip />
<el-table-column prop="tenantId" label="租户 ID" width="120" />
<el-table-column prop="sessionId" label="会话 ID" width="280" show-overflow-tooltip />
<el-table-column prop="tenantId" label="租户 ID" width="280" show-overflow-tooltip />
<el-table-column prop="messageCount" label="消息数" width="100" align="center" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
@ -41,6 +40,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="channelType" label="渠道" width="100" />
<el-table-column prop="startTime" label="开始时间" width="180" />
<el-table-column label="操作" fixed="right" width="120" align="center">
<template #default="scope">
@ -50,7 +50,6 @@
</base-table>
</el-card>
<!-- 全链路追踪详情抽屉 [AC-ASA-07] -->
<el-drawer
v-model="drawerVisible"
title="会话全链路追踪详情"
@ -59,40 +58,50 @@
>
<div v-loading="detailLoading" class="detail-container">
<el-empty v-if="!sessionDetail && !detailLoading" description="暂无追踪详情" />
<el-timeline v-else>
<el-timeline-item
v-for="(msg, index) in sessionDetail?.messages"
:key="index"
:timestamp="msg.timestamp"
placement="top"
:type="msg.role === 'user' ? 'primary' : 'success'"
>
<el-card shadow="never" class="msg-card">
<div class="msg-header">
<span class="role-tag" :class="msg.role">{{ msg.role === 'user' ? 'USER' : 'ASSISTANT' }}</span>
</div>
<div class="msg-content">{{ msg.content }}</div>
<div v-else>
<el-descriptions :column="1" border class="session-info">
<el-descriptions-item label="会话ID">{{ sessionDetail?.sessionId }}</el-descriptions-item>
<el-descriptions-item label="消息数">{{ sessionDetail?.messages?.length || 0 }}</el-descriptions-item>
</el-descriptions>
<!-- 展示追踪信息检索命中工具调用等 [AC-ASA-07] -->
<div v-if="msg.trace" class="trace-info">
<el-collapse class="trace-collapse">
<el-collapse-item v-if="msg.trace.retrieval" title="检索追踪 (Retrieval)" name="retrieval">
<div v-for="(hit, hIdx) in msg.trace.retrieval" :key="hIdx" class="hit-item">
<div class="hit-meta">
<el-tag size="small" type="success">Score: {{ hit.score }}</el-tag>
<span class="hit-source" v-if="hit.source">来源: {{ hit.source }}</span>
</div>
<div class="hit-text">{{ hit.content }}</div>
</div>
</el-collapse-item>
<el-collapse-item v-if="msg.trace.tool_calls" title="工具调用 (Tool Calls)" name="tools">
<pre class="code-block"><code>{{ JSON.stringify(msg.trace.tool_calls, null, 2) }}</code></pre>
</el-collapse-item>
</el-collapse>
<el-divider content-position="left">消息记录</el-divider>
<el-timeline>
<el-timeline-item
v-for="(msg, index) in sessionDetail?.messages"
:key="index"
:timestamp="msg.timestamp"
placement="top"
:type="msg.role === 'user' ? 'primary' : 'success'"
>
<el-card shadow="never" class="msg-card">
<div class="msg-header">
<span class="role-tag" :class="msg.role">{{ msg.role === 'user' ? 'USER' : 'ASSISTANT' }}</span>
</div>
<div class="msg-content">{{ msg.content }}</div>
</el-card>
</el-timeline-item>
</el-timeline>
<el-divider content-position="left" v-if="sessionDetail?.trace && (sessionDetail.trace.retrieval?.length || sessionDetail.trace.tools?.length)">
追踪信息
</el-divider>
<el-collapse v-if="sessionDetail?.trace">
<el-collapse-item v-if="sessionDetail.trace.retrieval?.length" title="检索追踪 (Retrieval)" name="retrieval">
<div v-for="(hit, hIdx) in sessionDetail.trace.retrieval" :key="hIdx" class="hit-item">
<div class="hit-meta">
<el-tag size="small" type="success">Score: {{ hit.score }}</el-tag>
<span class="hit-source" v-if="hit.source">来源: {{ hit.source }}</span>
</div>
<div class="hit-text">{{ hit.content }}</div>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
</el-collapse-item>
<el-collapse-item v-if="sessionDetail.trace.tools?.length" title="工具调用 (Tool Calls)" name="tools">
<pre class="code-block"><code>{{ JSON.stringify(sessionDetail.trace.tools, null, 2) }}</code></pre>
</el-collapse-item>
</el-collapse>
</div>
</div>
</el-drawer>
</div>
@ -128,6 +137,8 @@ const getList = async () => {
const res: any = await listSessions(queryParams)
tableData.value = res.data || []
total.value = res.pagination?.total || 0
} catch (error) {
console.error('Failed to fetch sessions:', error)
} finally {
loading.value = false
}
@ -143,12 +154,13 @@ const resetQuery = () => {
handleQuery()
}
/** 获取并展示全链路追踪详情 [AC-ASA-07] */
const handleTrace = async (row: any) => {
drawerVisible.value = true
detailLoading.value = true
try {
sessionDetail.value = await getSessionDetail(row.sessionId)
} catch (error) {
console.error('Failed to fetch session detail:', error)
} finally {
detailLoading.value = false
}
@ -164,15 +176,13 @@ onMounted(() => {
.card-header { display: flex; justify-content: space-between; align-items: center; }
.title { font-size: 16px; font-weight: bold; }
.detail-container { padding: 10px 20px; }
.session-info { margin-bottom: 20px; }
.msg-card { border-radius: 8px; margin-bottom: 10px; }
.msg-header { margin-bottom: 8px; }
.role-tag { font-size: 11px; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
.role-tag.user { background-color: #ecf5ff; color: #409eff; }
.role-tag.assistant { background-color: #f0f9eb; color: #67c23a; }
.msg-content { font-size: 14px; line-height: 1.6; color: #333; white-space: pre-wrap; }
.trace-info { margin-top: 15px; border-top: 1px solid #f0f0f0; padding-top: 10px; }
.trace-collapse { border: none; }
:deep(.el-collapse-item__header) { height: 36px; font-size: 13px; color: #909399; }
.hit-item { padding: 10px; background-color: #f8f9fa; border-radius: 4px; margin-bottom: 8px; }
.hit-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.hit-source { font-size: 11px; color: #999; }

View File

@ -1,7 +1,6 @@
<template>
<div class="rag-lab-container">
<el-row :gutter="20">
<!-- 左侧调试输入 [AC-ASA-05] -->
<el-col :span="10">
<el-card header="调试输入">
<el-form label-position="top">
@ -19,8 +18,14 @@
multiple
placeholder="请选择知识库"
style="width: 100%"
:loading="kbLoading"
>
<el-option label="默认知识库" value="default" />
<el-option
v-for="kb in knowledgeBases"
:key="kb.id"
:label="`${kb.name} (${kb.documentCount}个文档)`"
:value="kb.id"
/>
</el-select>
</el-form-item>
<el-form-item label="参数配置">
@ -46,7 +51,6 @@
</el-card>
</el-col>
<!-- 右侧实验结果 [AC-ASA-05] -->
<el-col :span="14">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="召回片段" name="retrieval">
@ -76,6 +80,14 @@
<pre><code>{{ results.finalPrompt }}</code></pre>
</div>
</el-tab-pane>
<el-tab-pane label="诊断信息" name="diagnostics">
<div v-if="!results.diagnostics" class="placeholder-text">
等待实验运行...
</div>
<div v-else class="diagnostics-view">
<pre><code>{{ JSON.stringify(results.diagnostics, null, 2) }}</code></pre>
</div>
</el-tab-pane>
</el-tabs>
</el-col>
</el-row>
@ -83,16 +95,25 @@
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { runRagExperiment } from '@/api/rag'
import { listKnowledgeBases } from '@/api/kb'
interface KnowledgeBase {
id: string
name: string
documentCount: number
}
const loading = ref(false)
const kbLoading = ref(false)
const activeTab = ref('retrieval')
const knowledgeBases = ref<KnowledgeBase[]>([])
const queryParams = reactive({
query: '',
kbIds: [],
kbIds: [] as string[],
params: {
topK: 3,
threshold: 0.5
@ -100,11 +121,23 @@ const queryParams = reactive({
})
const results = reactive({
retrievalResults: [],
finalPrompt: ''
retrievalResults: [] as any[],
finalPrompt: '',
diagnostics: null as any
})
/** 运行实验 [AC-ASA-05] */
const fetchKnowledgeBases = async () => {
kbLoading.value = true
try {
const res: any = await listKnowledgeBases()
knowledgeBases.value = res.data || []
} catch (error) {
console.error('Failed to fetch knowledge bases:', error)
} finally {
kbLoading.value = false
}
}
const handleRun = async () => {
if (!queryParams.query.trim()) {
ElMessage.warning('请输入查询 Query')
@ -116,14 +149,20 @@ const handleRun = async () => {
const res: any = await runRagExperiment(queryParams)
results.retrievalResults = res.retrievalResults || []
results.finalPrompt = res.finalPrompt || ''
results.diagnostics = res.diagnostics || null
activeTab.value = 'retrieval'
ElMessage.success('实验运行成功')
} catch (err) {
console.error(err)
ElMessage.error('实验运行失败')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchKnowledgeBases()
})
</script>
<style scoped>
@ -148,14 +187,14 @@ const handleRun = async () => {
}
.source { font-size: 12px; color: #909399; }
.result-content { font-size: 14px; line-height: 1.6; color: #303133; }
.prompt-view {
.prompt-view, .diagnostics-view {
background-color: #f5f7fa;
padding: 15px;
border-radius: 4px;
max-height: 600px;
overflow-y: auto;
}
.prompt-view pre {
.prompt-view pre, .diagnostics-view pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;

View File

@ -3,8 +3,9 @@ Admin API routes for AI Service management.
[AC-ASA-01, AC-ASA-02, AC-ASA-05, AC-ASA-07, AC-ASA-08] Admin management endpoints.
"""
from app.api.admin.dashboard import router as dashboard_router
from app.api.admin.kb import router as kb_router
from app.api.admin.rag import router as rag_router
from app.api.admin.sessions import router as sessions_router
__all__ = ["kb_router", "rag_router", "sessions_router"]
__all__ = ["dashboard_router", "kb_router", "rag_router", "sessions_router"]

View File

@ -0,0 +1,84 @@
"""
Dashboard statistics endpoints.
Provides overview statistics for the admin dashboard.
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.core.exceptions import MissingTenantIdException
from app.core.tenant import get_tenant_id
from app.models import ErrorResponse
from app.models.entities import ChatMessage, ChatSession, Document, KnowledgeBase
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/dashboard", tags=["Dashboard"])
def get_current_tenant_id() -> str:
"""Dependency to get current tenant ID or raise exception."""
tenant_id = get_tenant_id()
if not tenant_id:
raise MissingTenantIdException()
return tenant_id
@router.get(
"/stats",
operation_id="getDashboardStats",
summary="Get dashboard statistics",
description="Get overview statistics for the admin dashboard.",
responses={
200: {"description": "Dashboard statistics"},
401: {"description": "Unauthorized", "model": ErrorResponse},
403: {"description": "Forbidden", "model": ErrorResponse},
},
)
async def get_dashboard_stats(
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> JSONResponse:
"""
Get dashboard statistics including knowledge bases, messages, and activity.
"""
logger.info(f"Getting dashboard stats: tenant={tenant_id}")
kb_count_stmt = select(func.count()).select_from(KnowledgeBase).where(
KnowledgeBase.tenant_id == tenant_id
)
kb_result = await session.execute(kb_count_stmt)
kb_count = kb_result.scalar() or 0
msg_count_stmt = select(func.count()).select_from(ChatMessage).where(
ChatMessage.tenant_id == tenant_id
)
msg_result = await session.execute(msg_count_stmt)
msg_count = msg_result.scalar() or 0
doc_count_stmt = select(func.count()).select_from(Document).where(
Document.tenant_id == tenant_id
)
doc_result = await session.execute(doc_count_stmt)
doc_count = doc_result.scalar() or 0
session_count_stmt = select(func.count()).select_from(ChatSession).where(
ChatSession.tenant_id == tenant_id
)
session_result = await session.execute(session_count_stmt)
session_count = session_result.scalar() or 0
return JSONResponse(
content={
"knowledgeBases": kb_count,
"totalMessages": msg_count,
"totalDocuments": doc_count,
"totalSessions": session_count,
}
)

View File

@ -8,15 +8,16 @@ import os
import uuid
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
from fastapi import APIRouter, BackgroundTasks, Depends, Query, UploadFile, File, Form
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.core.exceptions import MissingTenantIdException
from app.core.tenant import get_tenant_id
from app.models import ErrorResponse
from app.models.entities import DocumentStatus, IndexJobStatus
from app.models.entities import DocumentStatus, IndexJob, IndexJobStatus
from app.services.kb import KBService
logger = logging.getLogger(__name__)
@ -32,6 +33,57 @@ def get_current_tenant_id() -> str:
return tenant_id
@router.get(
"/knowledge-bases",
operation_id="listKnowledgeBases",
summary="Query knowledge base list",
description="Get list of knowledge bases for the current tenant.",
responses={
200: {"description": "Knowledge base list"},
401: {"description": "Unauthorized", "model": ErrorResponse},
403: {"description": "Forbidden", "model": ErrorResponse},
},
)
async def list_knowledge_bases(
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> JSONResponse:
"""
List all knowledge bases for the current tenant.
"""
logger.info(f"Listing knowledge bases: tenant={tenant_id}")
kb_service = KBService(session)
knowledge_bases = await kb_service.list_knowledge_bases(tenant_id)
kb_ids = [str(kb.id) for kb in knowledge_bases]
doc_counts = {}
if kb_ids:
from sqlalchemy import func
from app.models.entities import Document
count_stmt = (
select(Document.kb_id, func.count(Document.id).label("count"))
.where(Document.tenant_id == tenant_id, Document.kb_id.in_(kb_ids))
.group_by(Document.kb_id)
)
count_result = await session.execute(count_stmt)
for row in count_result:
doc_counts[row.kb_id] = row.count
data = []
for kb in knowledge_bases:
kb_id_str = str(kb.id)
data.append({
"id": kb_id_str,
"name": kb.name,
"documentCount": doc_counts.get(kb_id_str, 0),
"createdAt": kb.created_at.isoformat() + "Z",
})
return JSONResponse(content={"data": data})
@router.get(
"/documents",
operation_id="listDocuments",
@ -70,17 +122,24 @@ async def list_documents(
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
data = [
{
data = []
for doc in documents:
job_stmt = select(IndexJob).where(
IndexJob.tenant_id == tenant_id,
IndexJob.doc_id == doc.id,
).order_by(IndexJob.created_at.desc())
job_result = await session.execute(job_stmt)
latest_job = job_result.scalar_one_or_none()
data.append({
"docId": str(doc.id),
"kbId": doc.kb_id,
"fileName": doc.file_name,
"status": doc.status,
"jobId": str(latest_job.id) if latest_job else None,
"createdAt": doc.created_at.isoformat() + "Z",
"updatedAt": doc.updated_at.isoformat() + "Z",
}
for doc in documents
]
})
return JSONResponse(
content={
@ -109,6 +168,7 @@ async def list_documents(
async def upload_document(
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
session: Annotated[AsyncSession, Depends(get_session)],
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
kb_id: str = Form(...),
) -> JSONResponse:
@ -133,7 +193,11 @@ async def upload_document(
file_type=file.content_type,
)
_schedule_indexing(tenant_id, str(job.id), str(document.id), file_content)
await session.commit()
background_tasks.add_task(
_index_document, tenant_id, str(job.id), str(document.id), file_content
)
return JSONResponse(
status_code=202,
@ -145,85 +209,87 @@ async def upload_document(
)
def _schedule_indexing(tenant_id: str, job_id: str, doc_id: str, content: bytes):
async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: bytes):
"""
Schedule background indexing task.
Background indexing task.
For MVP, we simulate indexing with a simple text extraction.
In production, this would use a task queue like Celery.
"""
from app.core.database import async_session_maker
from app.services.kb import KBService
from app.core.qdrant_client import get_qdrant_client
from qdrant_client.models import PointStruct
import hashlib
import asyncio
async def run_indexing():
from app.core.database import async_session_maker
from app.services.kb import KBService
from app.core.qdrant_client import get_qdrant_client
from qdrant_client.models import PointStruct
import hashlib
await asyncio.sleep(1)
await asyncio.sleep(1)
async with async_session_maker() as session:
kb_service = KBService(session)
try:
await kb_service.update_job_status(
tenant_id, job_id, IndexJobStatus.PROCESSING.value, progress=10
)
await session.commit()
async with async_session_maker() as session:
kb_service = KBService(session)
try:
await kb_service.update_job_status(
tenant_id, job_id, IndexJobStatus.PROCESSING.value, progress=10
)
text = content.decode("utf-8", errors="ignore")
text = content.decode("utf-8", errors="ignore")
chunks = [text[i:i+500] for i in range(0, len(text), 500)]
chunks = [text[i:i+500] for i in range(0, len(text), 500)]
qdrant = await get_qdrant_client()
await qdrant.ensure_collection_exists(tenant_id)
qdrant = await get_qdrant_client()
await qdrant.ensure_collection_exists(tenant_id)
points = []
for i, chunk in enumerate(chunks):
hash_obj = hashlib.sha256(chunk.encode())
hash_bytes = hash_obj.digest()
embedding = []
for j in range(0, min(len(hash_bytes) * 8, 1536)):
byte_idx = j // 8
bit_idx = j % 8
if byte_idx < len(hash_bytes):
val = (hash_bytes[byte_idx] >> bit_idx) & 1
embedding.append(float(val))
else:
embedding.append(0.0)
while len(embedding) < 1536:
points = []
for i, chunk in enumerate(chunks):
hash_obj = hashlib.sha256(chunk.encode())
hash_bytes = hash_obj.digest()
embedding = []
for j in range(0, min(len(hash_bytes) * 8, 1536)):
byte_idx = j // 8
bit_idx = j % 8
if byte_idx < len(hash_bytes):
val = (hash_bytes[byte_idx] >> bit_idx) & 1
embedding.append(float(val))
else:
embedding.append(0.0)
while len(embedding) < 1536:
embedding.append(0.0)
points.append(
PointStruct(
id=str(uuid.uuid4()),
vector=embedding[:1536],
payload={
"text": chunk,
"source": doc_id,
"chunk_index": i,
},
)
points.append(
PointStruct(
id=str(uuid.uuid4()),
vector=embedding[:1536],
payload={
"text": chunk,
"source": doc_id,
"chunk_index": i,
},
)
if points:
await qdrant.upsert_vectors(tenant_id, points)
await kb_service.update_job_status(
tenant_id, job_id, IndexJobStatus.COMPLETED.value, progress=100
)
logger.info(
f"[AC-ASA-01] Indexing completed: tenant={tenant_id}, "
f"job_id={job_id}, chunks={len(chunks)}"
)
if points:
await qdrant.upsert_vectors(tenant_id, points)
except Exception as e:
logger.error(f"[AC-ASA-01] Indexing failed: {e}")
await kb_service.update_job_status(
tenant_id, job_id, IndexJobStatus.COMPLETED.value, progress=100
)
await session.commit()
logger.info(
f"[AC-ASA-01] Indexing completed: tenant={tenant_id}, "
f"job_id={job_id}, chunks={len(chunks)}"
)
except Exception as e:
logger.error(f"[AC-ASA-01] Indexing failed: {e}")
await session.rollback()
async with async_session_maker() as error_session:
kb_service = KBService(error_session)
await kb_service.update_job_status(
tenant_id, job_id, IndexJobStatus.FAILED.value,
progress=0, error_msg=str(e)
)
asyncio.create_task(run_indexing())
await error_session.commit()
@router.get(

View File

@ -120,6 +120,7 @@ async def list_sessions(
data.append({
"sessionId": s.session_id,
"tenantId": tenant_id,
"status": session_status,
"startTime": s.created_at.isoformat() + "Z",
"endTime": end_time_val,

View File

@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api import chat_router, health_router
from app.api.admin import kb_router, rag_router, sessions_router
from app.api.admin import dashboard_router, kb_router, rag_router, sessions_router
from app.core.config import get_settings
from app.core.database import close_db, init_db
from app.core.exceptions import (
@ -112,6 +112,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
app.include_router(health_router)
app.include_router(chat_router)
app.include_router(dashboard_router)
app.include_router(kb_router)
app.include_router(rag_router)
app.include_router(sessions_router)

View File

@ -45,14 +45,17 @@ class KBService:
Get existing KB or create default one.
"""
if kb_id:
stmt = select(KnowledgeBase).where(
KnowledgeBase.tenant_id == tenant_id,
KnowledgeBase.id == uuid.UUID(kb_id),
)
result = await self._session.execute(stmt)
existing_kb = result.scalar_one_or_none()
if existing_kb:
return existing_kb
try:
stmt = select(KnowledgeBase).where(
KnowledgeBase.tenant_id == tenant_id,
KnowledgeBase.id == uuid.UUID(kb_id),
)
result = await self._session.execute(stmt)
existing_kb = result.scalar_one_or_none()
if existing_kb:
return existing_kb
except ValueError:
pass
stmt = select(KnowledgeBase).where(
KnowledgeBase.tenant_id == tenant_id,
@ -276,3 +279,16 @@ class KBService:
logger.info(f"[AC-ASA-08] Deleted document: tenant={tenant_id}, doc_id={doc_id}")
return True
async def list_knowledge_bases(
self,
tenant_id: str,
) -> Sequence[KnowledgeBase]:
"""
List all knowledge bases for a tenant.
"""
stmt = select(KnowledgeBase).where(
KnowledgeBase.tenant_id == tenant_id
).order_by(col(KnowledgeBase.created_at).desc())
result = await self._session.execute(stmt)
return result.scalars().all()