Show retrieved knowledge chunks in audit logs

This commit is contained in:
2026-07-09 10:29:58 +08:00
parent d6b7ec1e52
commit f39e51a4d4
9 changed files with 172 additions and 3 deletions

View File

@@ -1463,6 +1463,21 @@ function buildChatQuery() {
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="AI 请求" name="aiLogs"> <el-tab-pane label="AI 请求" name="aiLogs">
<el-table :data="aiLogs" stripe> <el-table :data="aiLogs" stripe>
<el-table-column type="expand" width="48">
<template #default="{ row }">
<section v-if="row.retrievedChunks?.length" class="retrieval-chunks">
<article v-for="chunk in row.retrievedChunks" :key="`${row.id}-${chunk.index}`" class="retrieval-chunk">
<div class="chunk-head">
<strong>#{{ chunk.index }} {{ chunk.knowledgeName || '未知知识库' }}</strong>
<span>{{ chunk.title || '未命名片段' }}</span>
</div>
<pre>{{ chunk.content }}</pre>
<a v-if="chunk.sourceUrl" :href="chunk.sourceUrl" target="_blank" rel="noreferrer">查看来源</a>
</article>
</section>
<el-empty v-else description="本条请求未保存召回片段" :image-size="64" />
</template>
</el-table-column>
<el-table-column prop="sessionId" label="会话ID" width="90" /> <el-table-column prop="sessionId" label="会话ID" width="90" />
<el-table-column prop="modelName" label="模型" width="160" /> <el-table-column prop="modelName" label="模型" width="160" />
<el-table-column prop="knowledgeIds" label="知识库" width="120" /> <el-table-column prop="knowledgeIds" label="知识库" width="120" />
@@ -1528,6 +1543,17 @@ function buildChatQuery() {
<span>耗时{{ log.costMs || '-' }}ms</span> <span>耗时{{ log.costMs || '-' }}ms</span>
<span>时间{{ log.createdAt }}</span> <span>时间{{ log.createdAt }}</span>
</div> </div>
<section v-if="log.retrievedChunks?.length" class="retrieval-chunks">
<article v-for="chunk in log.retrievedChunks" :key="`${log.id}-${chunk.index}`" class="retrieval-chunk">
<div class="chunk-head">
<strong>#{{ chunk.index }} {{ chunk.knowledgeName || '未知知识库' }}</strong>
<span>{{ chunk.title || '未命名片段' }}</span>
</div>
<pre>{{ chunk.content }}</pre>
<a v-if="chunk.sourceUrl" :href="chunk.sourceUrl" target="_blank" rel="noreferrer">查看来源</a>
</article>
</section>
<el-empty v-else description="本条请求未保存召回片段" :image-size="64" />
<pre class="prompt-preview">{{ log.prompt || '无 Prompt 记录' }}</pre> <pre class="prompt-preview">{{ log.prompt || '无 Prompt 记录' }}</pre>
<p v-if="log.errorMessage" class="error-text">{{ log.errorMessage }}</p> <p v-if="log.errorMessage" class="error-text">{{ log.errorMessage }}</p>
</el-collapse-item> </el-collapse-item>

View File

@@ -712,6 +712,58 @@ textarea {
font-size: 13px; font-size: 13px;
} }
.retrieval-chunks {
display: grid;
gap: 12px;
margin: 12px 0;
}
.retrieval-chunk {
padding: 12px;
border: 1px solid #dce8e4;
border-radius: 6px;
background: #fbfdfc;
}
.chunk-head {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: baseline;
margin-bottom: 8px;
color: #203832;
}
.chunk-head strong {
color: #0f735d;
}
.chunk-head span {
color: #60746d;
font-size: 13px;
}
.retrieval-chunk pre {
max-height: 260px;
margin: 0;
padding: 10px;
overflow: auto;
border-radius: 6px;
background: #f4f7f6;
color: #263832;
white-space: pre-wrap;
word-break: break-word;
font-family: "SFMono-Regular", Consolas, "PingFang SC", monospace;
line-height: 1.65;
}
.retrieval-chunk a {
display: inline-flex;
margin-top: 8px;
color: #0f735d;
text-decoration: none;
}
.error-text { .error-text {
color: #b42318; color: #b42318;
} }

View File

@@ -133,6 +133,7 @@ export interface AiLogRecord {
modelName?: string | null; modelName?: string | null;
knowledgeIds?: string | null; knowledgeIds?: string | null;
retrieveCount: number; retrieveCount: number;
retrievedChunks: RetrievedKnowledgeChunk[];
inputToken?: number | null; inputToken?: number | null;
outputToken?: number | null; outputToken?: number | null;
totalToken?: number | null; totalToken?: number | null;
@@ -143,6 +144,15 @@ export interface AiLogRecord {
createdAt: string; createdAt: string;
} }
export interface RetrievedKnowledgeChunk {
index: number;
knowledgeId?: number | null;
knowledgeName: string;
title: string;
content: string;
sourceUrl?: string | null;
}
export interface ChatDetail { export interface ChatDetail {
session: ChatRecord; session: ChatRecord;
messages: ChatMessageRecord[]; messages: ChatMessageRecord[];

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0003_ai_log_retrieved_chunks"
down_revision = "0002_expand_model_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_ai_request_log", sa.Column("retrieved_chunks", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("sys_ai_request_log", "retrieved_chunks")

View File

@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import csv import csv
import json
from datetime import datetime from datetime import datetime
from io import StringIO from io import StringIO
from typing import Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response from fastapi.responses import Response
@@ -236,6 +238,7 @@ def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False) -> dict:
"modelName": log.model_name, "modelName": log.model_name,
"knowledgeIds": log.knowledge_ids, "knowledgeIds": log.knowledge_ids,
"retrieveCount": log.retrieve_count, "retrieveCount": log.retrieve_count,
"retrievedChunks": _parse_retrieved_chunks(log.retrieved_chunks),
"inputToken": log.input_token, "inputToken": log.input_token,
"outputToken": log.output_token, "outputToken": log.output_token,
"totalToken": log.total_token, "totalToken": log.total_token,
@@ -247,3 +250,29 @@ def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False) -> dict:
if include_prompt: if include_prompt:
data["prompt"] = log.prompt data["prompt"] = log.prompt
return data return data
def _parse_retrieved_chunks(raw: str | None) -> list[dict[str, Any]]:
if not raw:
return []
try:
chunks = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(chunks, list):
return []
normalized: list[dict[str, Any]] = []
for index, item in enumerate(chunks, start=1):
if not isinstance(item, dict):
continue
normalized.append(
{
"index": item.get("index") or index,
"knowledgeId": item.get("knowledgeId"),
"knowledgeName": item.get("knowledgeName") or "",
"title": item.get("title") or "",
"content": item.get("content") or "",
"sourceUrl": item.get("sourceUrl") or None,
}
)
return normalized

View File

@@ -19,6 +19,7 @@ class AiRequestLog(Base):
prompt: Mapped[str | None] = mapped_column(Text, nullable=True) prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
knowledge_ids: Mapped[str | None] = mapped_column(String(500), nullable=True) knowledge_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
retrieve_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) retrieve_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
retrieved_chunks: Mapped[str | None] = mapped_column(Text, nullable=True)
input_token: Mapped[int | None] = mapped_column(Integer, nullable=True) input_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
output_token: Mapped[int | None] = mapped_column(Integer, nullable=True) output_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_token: Mapped[int | None] = mapped_column(Integer, nullable=True) total_token: Mapped[int | None] = mapped_column(Integer, nullable=True)

View File

@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import Sequence
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.logs import AiRequestLog from app.models.logs import AiRequestLog
from app.services.rag_service import RetrievedChunk
class AiRequestLogService: class AiRequestLogService:
@@ -20,6 +24,7 @@ class AiRequestLogService:
input_token: int, input_token: int,
output_token: int, output_token: int,
cost_ms: int, cost_ms: int,
retrieved_chunks: Sequence[RetrievedChunk] | None = None,
) -> None: ) -> None:
db.add( db.add(
AiRequestLog( AiRequestLog(
@@ -30,6 +35,7 @@ class AiRequestLogService:
prompt=prompt, prompt=prompt,
knowledge_ids=knowledge_ids, knowledge_ids=knowledge_ids,
retrieve_count=retrieve_count, retrieve_count=retrieve_count,
retrieved_chunks=_dump_retrieved_chunks(retrieved_chunks),
input_token=input_token, input_token=input_token,
output_token=output_token, output_token=output_token,
total_token=input_token + output_token, total_token=input_token + output_token,
@@ -51,6 +57,7 @@ class AiRequestLogService:
retrieve_count: int, retrieve_count: int,
cost_ms: int, cost_ms: int,
error_message: str, error_message: str,
retrieved_chunks: Sequence[RetrievedChunk] | None = None,
) -> None: ) -> None:
db.add( db.add(
AiRequestLog( AiRequestLog(
@@ -61,8 +68,26 @@ class AiRequestLogService:
prompt=prompt, prompt=prompt,
knowledge_ids=knowledge_ids, knowledge_ids=knowledge_ids,
retrieve_count=retrieve_count, retrieve_count=retrieve_count,
retrieved_chunks=_dump_retrieved_chunks(retrieved_chunks),
cost_ms=cost_ms, cost_ms=cost_ms,
status="FAILED", status="FAILED",
error_message=error_message, error_message=error_message,
) )
) )
def _dump_retrieved_chunks(chunks: Sequence[RetrievedChunk] | None) -> str | None:
if not chunks:
return None
payload = [
{
"index": index,
"knowledgeId": chunk.knowledge_id,
"knowledgeName": chunk.knowledge_name,
"title": chunk.title,
"content": chunk.content,
"sourceUrl": chunk.source_url,
}
for index, chunk in enumerate(chunks, start=1)
]
return json.dumps(payload, ensure_ascii=False)

View File

@@ -87,6 +87,7 @@ class ChatService:
db.flush() db.flush()
started_at = perf_counter() started_at = perf_counter()
rag_result = None
try: try:
# 获取历史消息(不含刚插入的 user_message它还没 flush id # 获取历史消息(不含刚插入的 user_message它还没 flush id
history = list( history = list(
@@ -121,11 +122,12 @@ class ChatService:
message_id=user_message.id, message_id=user_message.id,
user_id=user.id, user_id=user.id,
model_name=None, model_name=None,
prompt=normalized_question, prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=None, knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
retrieve_count=0, retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms, cost_ms=cost_ms,
error_message=str(exc), error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
) )
db.commit() db.commit()
raise HTTPException( raise HTTPException(
@@ -168,6 +170,7 @@ class ChatService:
input_token=completion.input_token, input_token=completion.input_token,
output_token=completion.output_token, output_token=completion.output_token,
cost_ms=cost_ms, cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks,
) )
db.commit() db.commit()
return completion.answer return completion.answer

View File

@@ -80,6 +80,7 @@ class ChatStreamService:
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0, retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms, cost_ms=cost_ms,
error_message=str(exc), error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
) )
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -98,6 +99,7 @@ class ChatStreamService:
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0, retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms, cost_ms=cost_ms,
error_message="模型未返回有效内容", error_message="模型未返回有效内容",
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
) )
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
@@ -137,6 +139,7 @@ class ChatStreamService:
input_token=model_response.input_token if model_response is not None else 0, input_token=model_response.input_token if model_response is not None else 0,
output_token=_rough_token_count(answer), output_token=_rough_token_count(answer),
cost_ms=cost_ms, cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
) )
db.commit() db.commit()
@@ -205,6 +208,7 @@ class ChatStreamService:
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0, retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms, cost_ms=cost_ms,
error_message=str(exc), error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
) )
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -223,6 +227,7 @@ class ChatStreamService:
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0, retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms, cost_ms=cost_ms,
error_message="模型未返回有效内容", error_message="模型未返回有效内容",
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
) )
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
@@ -295,6 +300,7 @@ def _write_success(
input_token=model_response.input_token if model_response is not None else 0, input_token=model_response.input_token if model_response is not None else 0,
output_token=_rough_token_count(answer), output_token=_rough_token_count(answer),
cost_ms=cost_ms, cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
) )
db.commit() db.commit()