diff --git a/backend/app/api/routes/chat.py b/backend/app/api/routes/chat.py index 1d495c8..accd0e7 100644 --- a/backend/app/api/routes/chat.py +++ b/backend/app/api/routes/chat.py @@ -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, ) diff --git a/backend/app/schemas/chat.py b/backend/app/schemas/chat.py index 3880934..ceb838a 100644 --- a/backend/app/schemas/chat.py +++ b/backend/app/schemas/chat.py @@ -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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index e3cf403..03ccbfa 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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): diff --git a/backend/tests/test_catalog.py b/backend/tests/test_catalog.py index dc400dc..0be81c6 100644 --- a/backend/tests/test_catalog.py +++ b/backend/tests/test_catalog.py @@ -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): diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ace22a5..fd44697 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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='' diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 49fe087..a00280f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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) diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 98c321c..58710ef 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -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 diff --git a/frontend/src/services/localHistory.ts b/frontend/src/services/localHistory.ts new file mode 100644 index 0000000..d660b61 --- /dev/null +++ b/frontend/src/services/localHistory.ts @@ -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)} diff --git a/frontend/src/views/ForbiddenView.vue b/frontend/src/views/ForbiddenView.vue new file mode 100644 index 0000000..e7f0b35 --- /dev/null +++ b/frontend/src/views/ForbiddenView.vue @@ -0,0 +1 @@ +403 无权限当前账户没有访问此页面的权限。管理员能力仍必须由后端授权。返回首页 diff --git a/frontend/src/views/HistoryView.vue b/frontend/src/views/HistoryView.vue new file mode 100644 index 0000000..46df8f1 --- /dev/null +++ b/frontend/src/views/HistoryView.vue @@ -0,0 +1,10 @@ + +返回智能问答历史对话当前记录仅保存在本浏览器,尚未接入服务器持久化;清除浏览器数据后可能丢失。清除全部本地记录暂无本地记录。请先在智能问答页发起提问。问题时间操作{{item.question}}{{new Date(item.created_at).toLocaleString()}}查看删除{{selected.question}}{{selected.summary}} diff --git a/frontend/src/views/NotFoundView.vue b/frontend/src/views/NotFoundView.vue new file mode 100644 index 0000000..aefae3a --- /dev/null +++ b/frontend/src/views/NotFoundView.vue @@ -0,0 +1 @@ +404 页面不存在请检查地址,或返回智能问答首页。返回首页
当前账户没有访问此页面的权限。管理员能力仍必须由后端授权。
当前记录仅保存在本浏览器,尚未接入服务器持久化;清除浏览器数据后可能丢失。
暂无本地记录。请先在智能问答页发起提问。
{{selected.summary}}
请检查地址,或返回智能问答首页。