feat: 聊天接口接入已核验知识检索 #46
@@ -1,15 +1,34 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.chat import ChatRequest, ChatResponse
|
||||
from app.schemas.retrieval import RetrievalRequest
|
||||
from app.services.retrieval import retrieve
|
||||
|
||||
router = APIRouter(tags=["chat"])
|
||||
|
||||
|
||||
@router.post("/chat", response_model=ChatResponse)
|
||||
def chat(payload: ChatRequest) -> ChatResponse:
|
||||
# TODO: retrieval -> context assembly -> LLM -> evidence/source checks
|
||||
def chat(payload: ChatRequest, db: Session = Depends(get_db)) -> ChatResponse:
|
||||
"""运行可复核的关键词检索;模型未配置时绝不伪造生成式回答。"""
|
||||
result = retrieve(db, RetrievalRequest(query=payload.question, top_k=5))
|
||||
if result.status == "no_evidence":
|
||||
return ChatResponse(
|
||||
answer="未检索到可用的已核验知识依据。请补充更具体的关键词,或由管理员补充并审核资料。",
|
||||
status="no_evidence",
|
||||
sources=[],
|
||||
retrieval_run_id=result.run_id,
|
||||
)
|
||||
return ChatResponse(
|
||||
answer=f"已收到问题:{payload.question}。当前为基础壳子,尚未接入知识检索或大语言模型。",
|
||||
status="stub",
|
||||
sources=[],
|
||||
answer=(
|
||||
f"已检索到 {result.total_hits} 条已核验知识依据。当前尚未配置大语言模型,"
|
||||
"因此系统仅返回可追溯证据,不生成或改写答案。"
|
||||
),
|
||||
status="retrieval_only",
|
||||
sources=[
|
||||
{"title": hit.title, "source_url": hit.source_url}
|
||||
for hit in result.results
|
||||
],
|
||||
retrieval_run_id=result.run_id,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -12,5 +15,6 @@ class SourcePreview(BaseModel):
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
answer: str
|
||||
status: str
|
||||
status: Literal["retrieval_only", "no_evidence"]
|
||||
sources: list[SourcePreview] = []
|
||||
retrieval_run_id: UUID
|
||||
|
||||
@@ -271,7 +271,9 @@ def test_all_management_routes_require_admin_and_history_is_private(anonymous_cl
|
||||
for method, path in paths:
|
||||
assert c.request(method, path, json={}).status_code == 403, path
|
||||
assert c.post("/api/retrieval", json={"query": "占位"}).json()["status"] == "no_evidence"
|
||||
assert c.post("/api/chat", json={"question": "占位"}).json()["status"] == "stub"
|
||||
response = c.post("/api/chat", json={"question": "占位"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "no_evidence"
|
||||
|
||||
|
||||
def test_admin_access_live_role_and_no_csrf_bypass(client, migrated_database):
|
||||
|
||||
@@ -59,12 +59,28 @@ def review(client, item, status="verified"):
|
||||
)
|
||||
|
||||
|
||||
def test_health_docs_and_stub(client):
|
||||
def test_health_docs_and_chat_without_evidence(client):
|
||||
assert client.get("/api/health").json()["status"] == "ok"
|
||||
assert client.get("/api/ready").json()["database"] == "connected"
|
||||
assert client.get("/docs").status_code == 200
|
||||
assert "/api/knowledge/{item_id}/review" in client.get("/openapi.json").json()["paths"]
|
||||
assert client.post("/api/chat", json={"question": "测试"}).json()["status"] == "stub"
|
||||
response = client.post("/api/chat", json={"question": "测试"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "no_evidence"
|
||||
|
||||
|
||||
def test_chat_returns_real_retrieval_evidence_without_generation(client):
|
||||
item = knowledge(client, title="那拉提测试资料", content="那拉提文旅服务测试依据")
|
||||
assert review(client, item).status_code == 200
|
||||
|
||||
response = client.post("/api/chat", json={"question": "那拉提文旅服务"})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "retrieval_only"
|
||||
assert "不生成" in body["answer"]
|
||||
assert body["retrieval_run_id"]
|
||||
assert body["sources"] == [{"title": "那拉提测试资料", "source_url": "https://example.com/"}]
|
||||
|
||||
|
||||
def test_legacy_create_and_missing_evidence(client):
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Activity, BookOpen, Bot, CheckCircle2, ChevronRight, Database, FileText
|
||||
import { createKnowledge, createSource, getHealth, getKnowledge, getSources, type SourceItem } from './api'
|
||||
import { retrieveHybrid } from './services/hybrid'
|
||||
import { getCurrentUser } from './services/auth'
|
||||
import { saveLocalHistory } from './services/localHistory'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { SessionUser } from './types/auth'
|
||||
type Page='chat'|'knowledge'|'sources'|'evaluation'
|
||||
@@ -20,7 +21,7 @@ async function saveSource(){error.value='';notice.value='';if(!sourceForm.value.
|
||||
let sourceSearchTimer:number|undefined
|
||||
watch(sourceSearch,()=>{window.clearTimeout(sourceSearchTimer);sourceSearchTimer=window.setTimeout(refreshSources,260)})
|
||||
onMounted(async()=>{try{currentUser.value=await getCurrentUser()}catch{router.replace('/login');return}try{await getHealth();apiStatus.value='已连接'}catch{apiStatus.value='未连接'}if(currentUser.value.role==='admin')await Promise.all([refresh(),refreshSources()])})
|
||||
async function send(){const q=question.value.trim();if(!q||busy.value)return;messages.value.push({role:'user',content:q});question.value='';busy.value=true;try{const r=await retrieveHybrid({query:q,top_k:5,candidate_k:50});const content=r.status==='no_evidence'?'未检索到可用的已核验知识依据。请调整问题或补充审核后的资料。':`已通过 RRF 混合检索命中 ${r.total_hits} 条依据(关键词 ${r.lexical_total_hits},向量 ${r.vector_total_hits},${r.elapsed_ms.toFixed(1)} ms)。当前仅展示检索证据,尚未接入大语言模型生成回答。`;messages.value.push({role:'assistant',content,sources:r.results.map(hit=>({title:`${hit.title}:${hit.snippet}`,source_url:hit.source_url}))})}catch(e){messages.value.push({role:'assistant',content:e instanceof Error?e.message:'暂时无法连接混合检索服务。'})}finally{busy.value=false}}
|
||||
async function send(){const q=question.value.trim();if(!q||busy.value)return;messages.value.push({role:'user',content:q});question.value='';busy.value=true;try{const r=await retrieveHybrid({query:q,top_k:5,candidate_k:50});const content=r.status==='no_evidence'?'未检索到可用的已核验知识依据。请调整问题或补充审核后的资料。':`已通过 RRF 混合检索命中 ${r.total_hits} 条依据(关键词 ${r.lexical_total_hits},向量 ${r.vector_total_hits},${r.elapsed_ms.toFixed(1)} ms)。当前仅展示检索证据,尚未接入大语言模型生成回答。`;messages.value.push({role:'assistant',content,sources:r.results.map(hit=>({title:`${hit.title}:${hit.snippet}`,source_url:hit.source_url}))});saveLocalHistory(q,content)}catch(e){const content=e instanceof Error?e.message:'暂时无法连接混合检索服务。';messages.value.push({role:'assistant',content});saveLocalHistory(q,content)}finally{busy.value=false}}
|
||||
function validUrl(value:string){try{if(value)new URL(value);return true}catch{return false}}
|
||||
async function save(){
|
||||
error.value='';notice.value=''
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { VectorRetrievalResult } from './services/vectors'
|
||||
import type { ChunkKeywordResult, HybridResult } from './services/hybrid'
|
||||
|
||||
export const getHealth = () => apiRequest<{status:string}>('/api/health')
|
||||
export const askQuestion = (question:string) => apiRequest<{answer:string;status:string;sources:{title:string;source_url?:string|null}[]}>('/api/chat',{method:'POST',body:JSON.stringify({question})})
|
||||
export const askQuestion = (question:string) => apiRequest<{answer:string;status:'retrieval_only'|'no_evidence';sources:{title:string;source_url?:string|null}[];retrieval_run_id:string}>('/api/chat',{method:'POST',body:JSON.stringify({question})})
|
||||
export const getKnowledge = () => apiRequest<{id:number;title:string;category:string;content:string;source_url:string|null;verification_status:string;version:number}[]>('/api/knowledge')
|
||||
export const createKnowledge = (payload:{title:string;category:string;content:string;source_url?:string;collected_at?:string}) => apiRequest('/api/knowledge',{method:'POST',body:JSON.stringify(payload)},true)
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ const router=createRouter({history:createWebHistory(),routes:[
|
||||
{path:'/',component:App},
|
||||
{path:'/login',component:()=>import('./views/LoginView.vue'),meta:{public:true}},
|
||||
{path:'/register',component:()=>import('./views/RegisterView.vue'),meta:{public:true}},
|
||||
{path:'/403',component:()=>import('./views/ForbiddenView.vue'),meta:{public:true}},
|
||||
{path:'/history',component:()=>import('./views/HistoryView.vue')},
|
||||
{path:'/profile',component:()=>import('./views/ProfileView.vue')},
|
||||
{path:'/settings',component:()=>import('./views/AccountSettingsView.vue')},
|
||||
{path:'/indexes',component:()=>import('./views/IndexManagementView.vue'),meta:{role:'admin'}},
|
||||
@@ -17,6 +19,7 @@ const router=createRouter({history:createWebHistory(),routes:[
|
||||
{path:'/sources',component:()=>import('./views/SourceManagementView.vue'),meta:{role:'admin'}},
|
||||
{path:'/retrieval-exports',component:()=>import('./views/RetrievalExportView.vue'),meta:{role:'admin'}},
|
||||
{path:'/admin',component:()=>import('./views/AdminView.vue'),meta:{role:'admin'}},
|
||||
{path:'/:pathMatch(.*)*',component:()=>import('./views/NotFoundView.vue'),meta:{public:true}},
|
||||
]})
|
||||
router.beforeEach(async to=>{if(to.meta.public)return true;try{role=(await getCurrentUser()).role}catch{return {path:'/login',query:{redirect:to.fullPath}}}if(to.meta.role&&role!==to.meta.role)return {path:'/profile',query:{forbidden:'1'}};return true})
|
||||
router.beforeEach(async to=>{if(to.meta.public)return true;try{role=(await getCurrentUser()).role}catch{return {path:'/login',query:{redirect:to.fullPath}}}if(to.meta.role&&role!==to.meta.role)return {path:'/403'};return true})
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type LocalHistoryItem={id:string;question:string;summary:string;created_at:string}
|
||||
const key='nalati-local-history-v1'
|
||||
export function readLocalHistory():LocalHistoryItem[]{try{const value=JSON.parse(sessionStorage.getItem(key)||'[]');return Array.isArray(value)?value:[]}catch{return []}}
|
||||
export function saveLocalHistory(question:string,summary:string){const item:LocalHistoryItem={id:crypto.randomUUID(),question,summary,created_at:new Date().toISOString()};sessionStorage.setItem(key,JSON.stringify([item,...readLocalHistory()].slice(0,100)))}
|
||||
export function removeLocalHistory(id:string){sessionStorage.setItem(key,JSON.stringify(readLocalHistory().filter(item=>item.id!==id)))}
|
||||
export function clearLocalHistory(){sessionStorage.removeItem(key)}
|
||||
@@ -0,0 +1 @@
|
||||
<template><main><h1>403 无权限</h1><p>当前账户没有访问此页面的权限。管理员能力仍必须由后端授权。</p><RouterLink to="/">返回首页</RouterLink></main></template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { clearLocalHistory, readLocalHistory, removeLocalHistory, type LocalHistoryItem } from '../services/localHistory'
|
||||
const items=ref<LocalHistoryItem[]>(readLocalHistory()),selected=ref<LocalHistoryItem>()
|
||||
const empty=computed(()=>!items.value.length)
|
||||
function refresh(){items.value=readLocalHistory()}
|
||||
function remove(id:string){removeLocalHistory(id);if(selected.value?.id===id)selected.value=undefined;refresh()}
|
||||
function clear(){if(window.confirm('确定清除本浏览器的全部本地对话记录吗?')){clearLocalHistory();selected.value=undefined;refresh()}}
|
||||
</script>
|
||||
<template><main><RouterLink to="/">返回智能问答</RouterLink><h1>历史对话</h1><p>当前记录仅保存在本浏览器,尚未接入服务器持久化;清除浏览器数据后可能丢失。</p><button :disabled="empty" @click="clear">清除全部本地记录</button><p v-if="empty">暂无本地记录。请先在智能问答页发起提问。</p><table v-else><thead><tr><th>问题</th><th>时间</th><th>操作</th></tr></thead><tbody><tr v-for="item in items" :key="item.id"><td>{{item.question}}</td><td>{{new Date(item.created_at).toLocaleString()}}</td><td><button @click="selected=item">查看</button><button @click="remove(item.id)">删除</button></td></tr></tbody></table><section v-if="selected"><h2>{{selected.question}}</h2><p>{{selected.summary}}</p></section></main></template>
|
||||
@@ -0,0 +1 @@
|
||||
<template><main><h1>404 页面不存在</h1><p>请检查地址,或返回智能问答首页。</p><RouterLink to="/">返回首页</RouterLink></main></template>
|
||||
Reference in New Issue
Block a user