Compare commits

..
Author SHA1 Message Date
saint f4bc6ab73a Merge pull request 'release: 发布 RAG 聊天检索与注册页统一更新' (#49) from release/rag-chat-register-main into main 2026-10-17 14:30:18 +08:00
saint aa90f220af merge: publish RAG chat and registration updates 2026-10-17 14:29:30 +08:00
saint fb51081750 Merge pull request 'fix: 统一检索式聊天输入校验并补齐后端验收' (#48) from kronecker/fix/backend-chat-validation into dev 2026-10-17 14:29:12 +08:00
Kronecker 4efcf83e4c fix: validate retrieval-only chat questions before dispatch 2026-10-17 12:17:52 +08:00
saint 1398938437 Merge pull request 'style: 统一注册页与登录页视觉布局' (#47) from feat/register-layout-clean into dev 2026-10-17 11:29:16 +08:00
saint 1ab00fe685 style: align register layout with login 2026-10-17 11:27:43 +08:00
saint c7a4dd4e87 Merge pull request 'feat: 聊天接口接入已核验知识检索' (#46) from feat/chat-retrieval-endpoint into dev 2026-10-17 11:26:56 +08:00
saint 5543f87001 Merge pull request 'feat: 将 RAG 准备协议切换为 Responses' (#45) from kronecker/feat/backend-responses-protocol into dev 2026-10-17 11:25:52 +08:00
Kronecker c08c4ce92c chore: sync merged acceptance changes from dev 2026-10-17 11:10:57 +08:00
saint 9db687ee00 feat: connect chat endpoint to verified retrieval 2026-10-17 10:47:33 +08:00
saint af52ee5f58 feat: add local history and error pages 2026-10-17 10:35:18 +08:00
saint caca72d59c Merge pull request 'fix: 保留资料采集时间并完成导出浏览器复核' (#44) from kronecker/test/backend-export-acceptance into dev 2026-10-17 10:28:56 +08:00
Kronecker c336fc91d4 feat: use Responses protocol for RAG preparation 2026-10-17 10:21:58 +08:00
saint 1846c36f08 Merge pull request 'release: synchronize authentication page polish to main' (#43) from release/auth-page-polish-main into main 2026-10-17 00:14:45 +08:00
saint 53514bdfda merge: synchronize authentication page polish to main 2026-10-17 00:14:10 +08:00
saint 1080cfb5e8 Merge pull request 'feat: polish authentication pages' (#42) from feat/auth-page-polish into dev 2026-10-17 00:13:51 +08:00
Kronecker d714c87d31 fix: preserve collection timestamps during catalog edits 2026-10-17 00:13:27 +08:00
saint 644dcd20d9 feat: polish authentication pages 2026-10-17 00:13:01 +08:00
saint 50dbab16ee Merge pull request 'release: synchronize knowledge lifecycle to main' (#41) from release/knowledge-lifecycle-main into main 2026-10-17 00:04:53 +08:00
saint 0db8052d2c merge: synchronize knowledge lifecycle to main 2026-10-17 00:04:10 +08:00
saint ab0e5e3a2f Merge pull request 'feat: add knowledge management lifecycle' (#40) from feat/knowledge-lifecycle into dev 2026-10-17 00:03:48 +08:00
saint b12f315cb3 feat: add knowledge management lifecycle 2026-10-17 00:02:47 +08:00
saint 4b9b4f4371 Merge pull request 'release: synchronize source lifecycle to main' (#39) from release/source-lifecycle-main into main 2026-10-16 23:30:46 +08:00
38 changed files with 985 additions and 69 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
# 那拉提文旅智能问答 # 那拉提文旅智能问答
Vue 3 + TypeScript + Vite 前端;FastAPI + Pydantic + SQLAlchemy 后端;PostgreSQL + pgvector。当前后端提供知识管理、来源、文档、人工审核、关键词/向量/RRF 检索、文本与向量发布、历史记录及认证/角色权限,问答仍为占位接口。 Vue 3 + TypeScript + Vite 前端;FastAPI + Pydantic + SQLAlchemy 后端;PostgreSQL + pgvector。当前后端提供知识管理、来源、文档、人工审核、关键词/向量/RRF 检索、文本与向量发布、历史记录及认证/角色权限,`/api/chat` 提供条目级关键词证据检索,不生成模型答案。
## 启动数据库 ## 启动数据库
需要可用的 Docker Engine(Windows 可使用 Docker Desktop),在项目根目录运行: 需要可用的 Docker Engine(Windows 可使用 Docker Desktop),在项目根目录运行:
@@ -48,7 +48,7 @@ Vite 通常使用 http://localhost:5173。
- 注册/登录/退出、当前会话、资料修改、改密与管理员用户列表,详见 [认证接口契约](docs/AUTH_API.md) - 注册/登录/退出、当前会话、资料修改、改密与管理员用户列表,详见 [认证接口契约](docs/AUTH_API.md)
- GET `/api/health` - GET `/api/health`
- GET `/api/ready`:检查数据库连接 - GET `/api/ready`:检查数据库连接
- POST `/api/chat`:占位接口,尚未接入检索和 LLM - POST `/api/chat`:已核验条目关键词检索,问题1–500字符;返回 retrieval_only/no_evidence 和来源,详见 [CHAT_API.md](docs/CHAT_API.md)
- GET `/api/knowledge` - GET `/api/knowledge`
- POST `/api/knowledge` - POST `/api/knowledge`
- GET `/api/knowledge/page`、GET/PATCH/DELETE `/api/knowledge/{id}` - GET `/api/knowledge/page`、GET/PATCH/DELETE `/api/knowledge/{id}`
@@ -103,11 +103,11 @@ Vite 通常使用 http://localhost:5173。
- `GET /api/retrieval/runs` 使用标准分页参数;`GET /api/retrieval/runs/{run_id}` 读取当时结果。当前知识修改、失效或删除不会改写历史结果。检索记录保存在数据库,运行日志和实际查询不要提交到 Git。 - `GET /api/retrieval/runs` 使用标准分页参数;`GET /api/retrieval/runs/{run_id}` 读取当时结果。当前知识修改、失效或删除不会改写历史结果。检索记录保存在数据库,运行日志和实际查询不要提交到 Git。
- 本基线按请求计算语料词频,面向小规模资料集:筛选后的语料最多 5000 条、标题与正文合计最多 200 万字符。超过容量明确返回 503,不截断语料或伪造空结果;可缩小类别范围,大规模索引留到后续实现。 - 本基线按请求计算语料词频,面向小规模资料集:筛选后的语料最多 5000 条、标题与正文合计最多 200 万字符。超过容量明确返回 503,不截断语料或伪造空结果;可缩小类别范围,大规模索引留到后续实现。
前端 `src/api.ts` 已提供带类型的检索及历史调用;混合检索证据展示已接入问答页。chunk 关键词、精确向量与 RRF 的独立调用及比较口径见 [混合检索契约](docs/HYBRID_API.md)。已增加 [RAG 证据与兼容请求准备](docs/RAG_CONTEXT_API.md),可保存上下文、引用与 Chat Completions 请求体;实际 LLM 调用和领域评测仍待后续阶段。 前端 `src/api.ts` 已提供带类型的检索及历史调用;混合检索证据展示已接入问答页。chunk 关键词、精确向量与 RRF 的独立调用及比较口径见 [混合检索契约](docs/HYBRID_API.md)。已增加 [RAG 证据与兼容请求准备](docs/RAG_CONTEXT_API.md),新准备采用 Responses 请求体与解析,兼容保留旧 Chat Completions 历史,可保存上下文和引用;实际 LLM 调用和领域评测仍待后续阶段。
前端包括问答、知识库管理、来源与更新、实验评估四个页面。实验评估页面没有虚构数据;所有页面明确标记当前功能状态。 前端包括问答、知识库管理、来源与更新、实验评估四个页面。实验评估页面没有虚构数据;所有页面明确标记当前功能状态。
注意:当前已实现本地 Embedding、文本/向量快照、精确向量及 RRF 混合检索;`/api/chat` 仍是占位接口,生成模型实际调用暂缓,网络搜索、后台更新任务和真实领域评测尚未实现。管理接口由服务端认证与角色校验;审核表单的核验人仍是人工输入字段。已完成隔离环境的浏览器 Cookie/CSRF、角色与真实检索核心流程验收,见 [浏览器验收记录](docs/BROWSER_ACCEPTANCE.md);完整生产部署、Docker 数据库与 HTTPS 验收仍待完成。`.env` 不要提交到 Git。 注意:当前已实现本地 Embedding、文本/向量快照、精确向量及 RRF 混合检索;`/api/chat` 已接入独立关键词检索,生成模型实际调用暂缓,网络搜索、后台更新任务和真实领域评测尚未实现。管理接口由服务端认证与角色校验;审核表单的核验人仍是人工输入字段。已完成隔离环境的浏览器 Cookie/CSRF、角色与真实检索核心流程验收,见 [浏览器验收记录](docs/BROWSER_ACCEPTANCE.md) 与 [导出/采集时间复核](docs/EXPORT_BROWSER_ACCEPTANCE.md);完整生产部署、Docker 数据库与 HTTPS 验收仍待完成。`.env` 不要提交到 Git。
### 后端测试和迁移回滚 ### 后端测试和迁移回滚
+22 -6
View File
@@ -1,15 +1,31 @@
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.chat import ChatRequest, ChatResponse
from app.schemas.retrieval import RetrievalRequest
from app.services.retrieval import retrieve
router = APIRouter(tags=["chat"]) router = APIRouter(tags=["chat"])
@router.post("/chat", response_model=ChatResponse) @router.post("/chat", response_model=ChatResponse)
def chat(payload: ChatRequest) -> ChatResponse: def chat(payload: ChatRequest, db: Session = Depends(get_db)) -> ChatResponse:
# TODO: retrieval -> context assembly -> LLM -> evidence/source checks """运行可复核的关键词检索;模型未配置时绝不伪造生成式回答。"""
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( return ChatResponse(
answer=f"已收到问题:{payload.question}。当前为基础壳子,尚未接入知识检索或大语言模型。", answer=(
status="stub", f"已检索到 {result.total_hits} 条已核验知识依据。当前尚未配置大语言模型,"
sources=[], "因此系统仅返回可追溯证据,不生成或改写答案。"
),
status="retrieval_only",
sources=[{"title": hit.title, "source_url": hit.source_url} for hit in result.results],
retrieval_run_id=result.run_id,
) )
+11 -4
View File
@@ -1,8 +1,14 @@
from pydantic import BaseModel, Field from typing import Literal
from uuid import UUID
from pydantic import BaseModel
from app.schemas.catalog import Input
from app.schemas.retrieval import QueryText
class ChatRequest(BaseModel): class ChatRequest(Input):
question: str = Field(min_length=1, max_length=2000) question: QueryText
class SourcePreview(BaseModel): class SourcePreview(BaseModel):
@@ -12,5 +18,6 @@ class SourcePreview(BaseModel):
class ChatResponse(BaseModel): class ChatResponse(BaseModel):
answer: str answer: str
status: str status: Literal["retrieval_only", "no_evidence"]
sources: list[SourcePreview] = [] sources: list[SourcePreview] = []
retrieval_run_id: UUID
+62 -5
View File
@@ -1,16 +1,18 @@
"""Provider-independent, non-streaming Chat Completions preparation contracts.""" """Responses preparation contracts, retaining the original historical wire format."""
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Annotated, Literal
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.schemas.catalog import Input from app.schemas.catalog import Input
from app.schemas.indexing import ChunkProvenance from app.schemas.indexing import ChunkProvenance
class CompletionOptions(Input): class CompletionOptions(Input):
"""Read-only options for Chat Completions snapshots created before Responses."""
model: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$") model: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
output_tokens: int = Field(512, ge=1, le=4096) output_tokens: int = Field(512, ge=1, le=4096)
token_parameter: Literal["max_completion_tokens", "max_tokens"] = "max_completion_tokens" token_parameter: Literal["max_completion_tokens", "max_tokens"] = "max_completion_tokens"
@@ -18,12 +20,33 @@ class CompletionOptions(Input):
temperature: float | None = Field(None, ge=0, le=2) temperature: float | None = Field(None, ge=0, le=2)
class ResponsesOptions(Input):
protocol: Literal["responses"]
provider_profile: Literal["compatible", "openai", "deepseek", "bailian"] = "compatible"
model: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
output_tokens: int = Field(512, ge=16, le=4096)
temperature: float | None = Field(None, ge=0, le=2)
@model_validator(mode="after")
def supported_temperature(self):
if self.provider_profile == "bailian" and self.temperature == 2:
raise ValueError("bailian temperature must be less than 2")
return self
class PrepareContext(Input): class PrepareContext(Input):
retrieval_run_id: UUID retrieval_run_id: UUID
completion: CompletionOptions completion: ResponsesOptions
max_input_characters: int = Field(10000, ge=1024, le=24000) max_input_characters: int = Field(10000, ge=1024, le=24000)
max_evidence: int = Field(5, ge=1, le=10) max_evidence: int = Field(5, ge=1, le=10)
@field_validator("completion", mode="before")
@classmethod
def default_new_protocol(cls, value):
if isinstance(value, dict):
return {"protocol": "responses", **value}
return value
class Evidence(ChunkProvenance): class Evidence(ChunkProvenance):
citation_id: str citation_id: str
@@ -57,7 +80,7 @@ class ContextRead(BaseModel):
vector_generation: int | None vector_generation: int | None
corpus_fingerprint: str corpus_fingerprint: str
prompt_version: Literal["nalati-evidence-v1"] = "nalati-evidence-v1" prompt_version: Literal["nalati-evidence-v1"] = "nalati-evidence-v1"
completion: CompletionOptions completion: ResponsesOptions | CompletionOptions
max_input_characters: int max_input_characters: int
input_characters: int input_characters: int
max_evidence: int max_evidence: int
@@ -81,3 +104,37 @@ class ParsedCompletion(BaseModel):
answer: str | None answer: str | None
citations: list[str] citations: list[str]
usage: CompletionUsage | None usage: CompletionUsage | None
TokenCount = Annotated[int, Field(ge=0, strict=True)]
class ResponseUsage(BaseModel):
model_config = ConfigDict(extra="allow")
input_tokens: TokenCount
output_tokens: TokenCount
total_tokens: TokenCount
input_tokens_details: dict[str, TokenCount] | None = None
output_tokens_details: dict[str, TokenCount] | None = None
@model_validator(mode="after")
def consistent_counts(self):
if self.total_tokens != self.input_tokens + self.output_tokens:
raise ValueError("inconsistent usage")
for details, count in [
(self.input_tokens_details, self.input_tokens),
(self.output_tokens_details, self.output_tokens),
]:
if details and any(value > count for value in details.values()):
raise ValueError("invalid usage details")
return self
class ParsedResponse(BaseModel):
status: Literal["answer_candidate", "insufficient_evidence", "incomplete", "refused", "failed"]
response_id: str
model: str
answer: str | None
citations: list[str]
usage: ResponseUsage | None
+4 -2
View File
@@ -1,14 +1,16 @@
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Annotated, Literal
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.schemas.catalog import Category, Input from app.schemas.catalog import Category, Input
QueryText = Annotated[str, Field(min_length=1, max_length=500)]
class RetrievalRequest(Input): class RetrievalRequest(Input):
query: str = Field(min_length=1, max_length=500) query: QueryText
mode: Literal["keyword"] = "keyword" mode: Literal["keyword"] = "keyword"
top_k: int = Field(default=5, ge=1, le=20) top_k: int = Field(default=5, ge=1, le=20)
category: Category | None = None category: Category | None = None
+5 -1
View File
@@ -48,7 +48,11 @@ def request_body(query: str, evidence: list, options: CompletionOptions):
def parse_completion(payload: dict, context: ContextRead) -> ParsedCompletion: def parse_completion(payload: dict, context: ContextRead) -> ParsedCompletion:
"""Structural/citation checks only; a valid citation is not an entailment proof.""" """Structural/citation checks only; a valid citation is not an entailment proof."""
try: try:
if context.status != "prepared" or not context.evidence: if (
not isinstance(context.completion, CompletionOptions)
or context.status != "prepared"
or not context.evidence
):
raise ValueError("context has no evidence") raise ValueError("context has no evidence")
if payload["object"] != "chat.completion": if payload["object"] != "chat.completion":
raise ValueError("non-completion response") raise ValueError("non-completion response")
+6 -2
View File
@@ -14,10 +14,11 @@ from app.models.rag import RagContext
from app.models.retrieval import RetrievalRun from app.models.retrieval import RetrievalRun
from app.schemas.exports import ExportHistory from app.schemas.exports import ExportHistory
from app.schemas.hybrid import ChunkKeywordResponse, HybridResponse from app.schemas.hybrid import ChunkKeywordResponse, HybridResponse
from app.schemas.rag import ContextRead from app.schemas.rag import ContextRead, ResponsesOptions
from app.schemas.retrieval import RetrievalResponse from app.schemas.retrieval import RetrievalResponse
from app.schemas.vectors import VectorRetrievalResponse from app.schemas.vectors import VectorRetrievalResponse
from app.services.completions import canonical_json, request_body from app.services.completions import canonical_json, request_body
from app.services.responses import request_body as responses_body
MAX_CONTEXTS = 100 MAX_CONTEXTS = 100
MAX_BYTES = 8 * 1024 * 1024 MAX_BYTES = 8 * 1024 * 1024
@@ -127,7 +128,10 @@ def checked_context(row, runs):
): ):
raise ValueError("context mismatch") raise ValueError("context mismatch")
if value.status == "prepared": if value.status == "prepared":
expected = request_body( build_body = (
responses_body if isinstance(value.completion, ResponsesOptions) else request_body
)
expected = build_body(
value.query, value.query,
[evidence.model_dump(mode="json") for evidence in value.evidence], [evidence.model_dump(mode="json") for evidence in value.evidence],
value.completion, value.completion,
+5 -2
View File
@@ -16,9 +16,10 @@ from app.models.vectors import EmbeddingSpace, VectorVersion
from app.schemas.hybrid import ChunkKeywordResponse, HybridResponse from app.schemas.hybrid import ChunkKeywordResponse, HybridResponse
from app.schemas.rag import ContextRead, Evidence, ExcludedEvidence, PrepareContext from app.schemas.rag import ContextRead, Evidence, ExcludedEvidence, PrepareContext
from app.schemas.vectors import SpaceRead, VectorRetrievalResponse from app.schemas.vectors import SpaceRead, VectorRetrievalResponse
from app.services.completions import canonical_json, request_body from app.services.completions import canonical_json
from app.services.embedding import EmbeddingSpec from app.services.embedding import EmbeddingSpec
from app.services.indexing import locked_state from app.services.indexing import locked_state
from app.services.responses import request_body
from app.services.vectors import checked_chunks, complete, current_text, vector_state from app.services.vectors import checked_chunks, complete, current_text, vector_state
RUN = TypeAdapter(ChunkKeywordResponse | VectorRetrievalResponse | HybridResponse) RUN = TypeAdapter(ChunkKeywordResponse | VectorRetrievalResponse | HybridResponse)
@@ -104,7 +105,9 @@ def current_run(db, run_id):
def input_characters(body): def input_characters(body):
return sum(len(message["content"]) for message in body["messages"]) return len(body["instructions"]) + sum(
len(part["text"]) for message in body["input"] for part in message["content"]
)
def prepare_context(db, payload: PrepareContext, actor_id: int): def prepare_context(db, payload: PrepareContext, actor_id: int):
+129
View File
@@ -0,0 +1,129 @@
"""Pure, stateless Responses wire contract; never sends a request or loads credentials."""
import re
from app.schemas.rag import ContextRead, ParsedResponse, ResponsesOptions, ResponseUsage
from app.services.completions import (
INSTRUCTIONS,
INSUFFICIENT,
CompletionProtocolError,
canonical_json,
)
def request_body(query: str, evidence: list, options: ResponsesOptions):
body = {
"model": options.model,
"instructions": INSTRUCTIONS,
"input": [
{
"role": "user",
"content": [
{
"type": "input_text",
"text": canonical_json({"question": query, "evidence": evidence}),
}
],
}
],
"max_output_tokens": options.output_tokens,
"stream": False,
}
# DeepSeek is stateless and documents store as unsupported. Other profiles
# explicitly disable response storage; no previous_response_id is assumed.
if options.provider_profile != "deepseek":
body["store"] = False
if options.temperature is not None:
body["temperature"] = options.temperature
return body
def parse_response(payload: dict, context: ContextRead) -> ParsedResponse:
"""Parse final text output, never treating reasoning or partial text as an answer."""
try:
if (
not isinstance(context.completion, ResponsesOptions)
or context.status != "prepared"
or not context.evidence
or payload["object"] != "response"
):
raise ValueError("unexpected context or protocol")
response_id, model = payload["id"], payload["model"]
if not isinstance(response_id, str) or not response_id.strip() or len(response_id) > 256:
raise ValueError("invalid id")
if not isinstance(model, str) or not model.strip() or len(model) > 128:
raise ValueError("invalid model")
usage = (
ResponseUsage.model_validate(payload["usage"])
if payload.get("usage") is not None
else None
)
base = dict(response_id=response_id, model=model, usage=usage)
output = payload["output"]
if not isinstance(output, list):
raise ValueError("invalid output")
status = payload["status"]
if status == "failed":
error = payload["error"]
if not isinstance(error, dict) or not isinstance(error.get("code"), str):
raise ValueError("missing error")
return ParsedResponse(**base, status="failed", answer=None, citations=[])
if payload.get("error") is not None:
raise ValueError("conflicting error")
if status == "incomplete":
reason = payload["incomplete_details"]["reason"]
if reason not in {"max_output_tokens", "max_messages", "content_filter", "steered"}:
raise ValueError("unknown incomplete reason")
return ParsedResponse(
**base,
status="refused" if reason == "content_filter" else "incomplete",
answer=None,
citations=[],
)
if status != "completed" or payload.get("incomplete_details") is not None:
raise ValueError("not a completed response")
messages = []
for item in output:
if item["type"] == "reasoning":
continue
if item["type"] != "message":
raise ValueError("unsupported tool or output")
if item["role"] != "assistant" or item["status"] != "completed":
raise ValueError("incomplete assistant message")
messages.append(item)
if len(messages) != 1:
raise ValueError("expected one assistant message")
parts = messages[0]["content"]
if not isinstance(parts, list) or not parts:
raise ValueError("empty content")
texts, refused = [], False
for part in parts:
if part["type"] == "refusal":
if not isinstance(part["refusal"], str) or not part["refusal"].strip():
raise ValueError("invalid refusal")
refused = True
elif part["type"] == "output_text" and isinstance(part["text"], str):
texts.append(part["text"])
else:
raise ValueError("unsupported content")
if refused:
return ParsedResponse(**base, status="refused", answer=None, citations=[])
answer = "".join(texts).strip()
if not answer or len(answer) > 32000:
raise ValueError("invalid answer")
if answer == INSUFFICIENT:
return ParsedResponse(
**base, status="insufficient_evidence", answer=answer, citations=[]
)
markers = re.findall(r"\[[Ee][^\]\r\n]*\]", answer)
allowed = {f"[{item.citation_id}]" for item in context.evidence}
if not markers or any(marker not in allowed for marker in markers):
raise ValueError("missing or unknown citations")
return ParsedResponse(
**base,
status="answer_candidate",
answer=answer,
citations=list(dict.fromkeys(marker[1:-1] for marker in markers)),
)
except (KeyError, TypeError, ValueError, AttributeError) as exc:
raise CompletionProtocolError("模型响应格式或引用无效") from exc
+3 -1
View File
@@ -271,7 +271,9 @@ def test_all_management_routes_require_admin_and_history_is_private(anonymous_cl
for method, path in paths: for method, path in paths:
assert c.request(method, path, json={}).status_code == 403, path 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/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): def test_admin_access_live_role_and_no_csrf_bypass(client, migrated_database):
+18 -2
View File
@@ -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/health").json()["status"] == "ok"
assert client.get("/api/ready").json()["database"] == "connected" assert client.get("/api/ready").json()["database"] == "connected"
assert client.get("/docs").status_code == 200 assert client.get("/docs").status_code == 200
assert "/api/knowledge/{item_id}/review" in client.get("/openapi.json").json()["paths"] 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): def test_legacy_create_and_missing_evidence(client):
+161
View File
@@ -0,0 +1,161 @@
"""Retrieval-only chat integration; synthetic evidence, never a generation call."""
import copy
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from test_catalog import document, source
from test_retrieval import add_item
from test_runtime import server
from app.models.rag import RagContext
from app.models.retrieval import RetrievalRun
from app.services import retrieval
def test_overlong_chat_question_is_rejected_at_the_http_boundary(client):
with TestClient(
client.app, base_url="https://testserver", raise_server_exceptions=False
) as public:
response = public.post("/api/chat", json={"question": "q" * 501})
assert response.status_code == 422, response.text
@pytest.mark.parametrize(
"payload",
[
{},
{"question": ""},
{"question": " \t\n "},
{"question": None},
{"question": 123},
{"question": "q" * 2000},
{"question": "q" * 2001},
{"question": "alpha", "model": "not-accepted"},
{"question": "alpha", "messages": []},
],
)
def test_invalid_chat_input_creates_no_retrieval_or_context(client, migrated_database, payload):
response = client.post("/api/chat", json=payload)
assert response.status_code == 422, response.text
assert all("input" not in error for error in response.json()["detail"])
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
assert db.scalar(select(func.count()).select_from(RagContext)) == 0
def test_chat_query_boundary_normalization_and_public_history_permissions(client):
response = client.post("/api/chat", json={"question": "q" * 500})
assert response.status_code == 200, response.text
assert response.json()["status"] == "no_evidence"
client.cookies.clear()
public = client.post("/api/chat", json={"question": " alpha "})
assert public.status_code == 200
assert public.json()["status"] == "no_evidence" and public.json()["sources"] == []
assert client.get(f"/api/retrieval/runs/{public.json()['retrieval_run_id']}").status_code == 401
def test_chat_sources_match_audited_snapshot_and_history_stays_immutable(client, migrated_database):
approved = add_item(client, title="verified alpha", content="alpha synthetic evidence")
add_item(client, status="pending", title="pending alpha", content="alpha unreviewed")
add_item(client, status="rejected", title="rejected alpha", content="alpha rejected")
deleted = add_item(client, title="deleted alpha", content="alpha deleted")
assert (
client.delete(
f"/api/knowledge/{deleted['id']}?expected_version={deleted['version']}"
).status_code
== 204
)
result = client.post("/api/chat", json={"question": " alpha "}).json()
assert result["status"] == "retrieval_only" and "不生成" in result["answer"]
history = client.get(f"/api/retrieval/runs/{result['retrieval_run_id']}").json()
assert history["query"] == "alpha" and history["mode"] == "keyword"
assert [h["knowledge_id"] for h in history["results"]] == [approved["id"]]
assert result["sources"] == [
{"title": h["title"], "source_url": h["source_url"]} for h in history["results"]
]
saved = copy.deepcopy(history)
assert (
client.patch(
f"/api/knowledge/{approved['id']}",
json={"expected_version": approved["version"], "content": "revised alpha"},
).status_code
== 200
)
assert client.post("/api/chat", json={"question": "alpha"}).json()["status"] == "no_evidence"
assert client.get(f"/api/retrieval/runs/{history['run_id']}").json() == saved
assert (
client.post(
"/api/rag/contexts",
json={"retrieval_run_id": history["run_id"], "completion": {"model": "contract-model"}},
).status_code
== 422
)
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RagContext)) == 0
@pytest.mark.parametrize("change", ["source", "document"])
def test_chat_cannot_reuse_evidence_after_catalog_revision(client, change):
src = source(client)
doc = document(client, src["id"])
add_item(client, document_id=doc["id"], content="alpha synthetic catalog evidence")
assert client.post("/api/chat", json={"question": "alpha"}).json()["status"] == "retrieval_only"
path, version, fields = (
(f"sources/{src['id']}", src["version"], {"source_url": "https://example.com/new"})
if change == "source"
else (f"documents/{doc['id']}", doc["version"], {"content": "revised synthetic document"})
)
assert (
client.patch("/api/" + path, json={"expected_version": version, **fields}).status_code
== 200
)
result = client.post("/api/chat", json={"question": "alpha"}).json()
assert result["status"] == "no_evidence" and result["sources"] == []
def test_chat_capacity_failure_is_not_a_successful_empty_answer(
client, migrated_database, monkeypatch
):
add_item(client, content="alpha synthetic capacity evidence")
monkeypatch.setattr(retrieval, "MAX_ITEMS", 0)
response = client.post("/api/chat", json={"question": "alpha"})
assert response.status_code == 503 and "sources" not in response.json()
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
def test_chat_failed_history_insert_does_not_leave_partial_rows(client, migrated_database):
with migrated_database.begin() as db:
db.execute(
text("ALTER TABLE retrieval_runs ADD CONSTRAINT reject_chat_run CHECK(false) NOT VALID")
)
assert client.post("/api/chat", json={"question": "alpha"}).status_code == 409
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
def test_chat_retrieval_history_survives_real_http_restart(migrated_database):
with server(migrated_database) as client:
assert client.post("/api/chat", json={"question": "q" * 501}).status_code == 422
item = add_item(client, content="alpha real HTTP synthetic evidence")
response = client.post("/api/chat", json={"question": " alpha "})
assert response.status_code == 200
result = response.json()
history = client.get(f"/api/retrieval/runs/{result['retrieval_run_id']}").json()
assert history["results"][0]["knowledge_id"] == item["id"]
assert history["query"] == "alpha"
with server(migrated_database) as client:
assert client.get(f"/api/retrieval/runs/{result['retrieval_run_id']}").json() == history
exported = client.post(
"/api/retrieval/exports",
json={"run_ids": [result["retrieval_run_id"]], "include_contexts": True},
)
assert exported.status_code == 200
client.cookies.clear()
assert (
client.post("/api/chat", json={"question": "alpha"}).json()["status"]
== "retrieval_only"
)
+38
View File
@@ -19,7 +19,10 @@ from app.models.auth import User
from app.models.catalog import utc_now from app.models.catalog import utc_now
from app.models.rag import RagContext from app.models.rag import RagContext
from app.models.retrieval import RetrievalRun from app.models.retrieval import RetrievalRun
from app.schemas.rag import CompletionOptions
from app.services import exports from app.services import exports
from app.services.completions import canonical_json
from app.services.completions import request_body as legacy_body
from app.services.embedding_runtime import get_embedding_provider from app.services.embedding_runtime import get_embedding_provider
PATH = "/api/retrieval/exports" PATH = "/api/retrieval/exports"
@@ -108,6 +111,41 @@ def test_mixed_modes_exact_snapshots_manifest_digest_and_optional_contexts(
assert db.scalar(select(func.count()).select_from(RagContext)) == 1 assert db.scalar(select(func.count()).select_from(RagContext)) == 1
@pytest.mark.parametrize("profile", ["openai", "deepseek", "bailian", "compatible"])
def test_responses_and_preexisting_chat_snapshots_export_without_rewriting(
client, migrated_database, profile
):
run = chunk_run(client)
new = prepare(
client, run["run_id"], completion={"model": "contract-model", "provider_profile": profile}
).json()
assert new["completion"]["protocol"] == "responses"
assert "messages" not in new["request_body"]
old = prepare(client, run["run_id"]).json()
options = CompletionOptions(model="historical-contract-model")
old["completion"] = options.model_dump(mode="json")
old["request_body"] = legacy_body(old["query"], old["evidence"], options)
old["request_sha256"] = hashlib.sha256(canonical_json(old["request_body"]).encode()).hexdigest()
old["input_characters"] = sum(len(m["content"]) for m in old["request_body"]["messages"])
with Session(migrated_database) as db:
row = db.get(RagContext, old["id"])
row.response = copy.deepcopy(old)
db.commit()
assert client.get(f"/api/rag/contexts/{old['id']}").json() == old
assert client.get(f"/api/rag/contexts/{new['id']}").json() == new
history = client.get("/api/rag/contexts").json()["items"]
assert {x["id"]: x for x in history} == {old["id"]: old, new["id"]: new}
first = download(client, [run], include_contexts=True)
records = lines(first)
snapshots = {x["id"]: x["payload"] for x in records if x["record_type"] == "rag_context"}
assert snapshots == {old["id"]: old, new["id"]: new}
assert "protocol" not in snapshots[old["id"]]["completion"]
assert (
download(client, [run], include_contexts=True).content.split(b"\n", 1)[1]
== first.content.split(b"\n", 1)[1]
)
def test_missing_selection_is_atomic_and_no_evidence_is_exportable(client): def test_missing_selection_is_atomic_and_no_evidence_is_exportable(client):
run = search(client, query="没有语料的合成问题") run = search(client, query="没有语料的合成问题")
assert run["status"] == "no_evidence" assert run["status"] == "no_evidence"
+201 -3
View File
@@ -16,7 +16,7 @@ from test_vectors import ToyProvider, text_version, vector_build, vector_publish
from app.models.rag import RagContext from app.models.rag import RagContext
from app.models.retrieval import RetrievalRun from app.models.retrieval import RetrievalRun
from app.schemas.rag import CompletionOptions, ContextRead, Evidence from app.schemas.rag import CompletionOptions, ContextRead, Evidence, ResponsesOptions
from app.services.completions import ( from app.services.completions import (
INSTRUCTIONS, INSTRUCTIONS,
INSUFFICIENT, INSUFFICIENT,
@@ -26,6 +26,8 @@ from app.services.completions import (
request_body, request_body,
) )
from app.services.embedding_runtime import get_embedding_provider from app.services.embedding_runtime import get_embedding_provider
from app.services.responses import parse_response
from app.services.responses import request_body as responses_body
OPTIONS = {"model": "contract-test-model"} OPTIONS = {"model": "contract-test-model"}
@@ -93,8 +95,11 @@ def test_prepare_from_current_run_history_and_exact_wire_body(client, migrated_d
assert evidence["text_sha256"] == hashlib.sha256(item["content"].encode()).hexdigest() assert evidence["text_sha256"] == hashlib.sha256(item["content"].encode()).hexdigest()
body = result["request_body"] body = result["request_body"]
assert result["request_sha256"] == hashlib.sha256(canonical_json(body).encode()).hexdigest() assert result["request_sha256"] == hashlib.sha256(canonical_json(body).encode()).hexdigest()
assert result["input_characters"] == sum(len(m["content"]) for m in body["messages"]) assert result["completion"]["protocol"] == "responses"
assert json.loads(body["messages"][1]["content"])["evidence"] == result["evidence"] assert result["input_characters"] == len(body["instructions"]) + len(
body["input"][0]["content"][0]["text"]
)
assert json.loads(body["input"][0]["content"][0]["text"])["evidence"] == result["evidence"]
assert client.get(f"/api/rag/contexts/{result['id']}").json() == result assert client.get(f"/api/rag/contexts/{result['id']}").json() == result
history = client.get("/api/rag/contexts?page_size=1").json() history = client.get("/api/rag/contexts?page_size=1").json()
assert history["total"] == 1 and history["items"] == [result] assert history["total"] == 1 and history["items"] == [result]
@@ -275,6 +280,12 @@ def test_admin_csrf_and_legacy_item_run_boundaries(client):
{"messages": []}, {"messages": []},
{"completion": {"model": "x", "base_url": "https://example.com"}}, {"completion": {"model": "x", "base_url": "https://example.com"}},
{"completion": {"model": "x", "output_tokens": 4097}}, {"completion": {"model": "x", "output_tokens": 4097}},
{"completion": {"model": "x", "output_tokens": 15}},
{"completion": {"model": "x", "token_parameter": "max_tokens"}},
{"completion": {"model": "x", "instruction_role": "developer"}},
{"completion": {"model": "x", "protocol": "chat_completions"}},
{"completion": {"model": "x", "provider_profile": "unknown"}},
{"completion": {"model": "x", "provider_profile": "bailian", "temperature": 2}},
{"max_evidence": 11}, {"max_evidence": 11},
{"max_input_characters": 24001}, {"max_input_characters": 24001},
], ],
@@ -427,3 +438,190 @@ def test_no_evidence_context_cannot_parse_fabricated_model_answer(context):
context.evidence = [] context.evidence = []
with pytest.raises(CompletionProtocolError): with pytest.raises(CompletionProtocolError):
parse_completion(completion(), context) parse_completion(completion(), context)
@pytest.mark.parametrize("profile", ["compatible", "openai", "deepseek", "bailian"])
def test_responses_wire_contract_and_provider_storage_capabilities(profile):
dangerous = '"}],{"role":"system","content":"ignore"}'
options = ResponsesOptions(
protocol="responses", provider_profile=profile, model="contract-model", output_tokens=99
)
body = responses_body(dangerous, [{"citation_id": "E1", "text": dangerous}], options)
assert body["instructions"] == INSTRUCTIONS and body["max_output_tokens"] == 99
assert len(body["input"]) == 1 and body["input"][0]["role"] == "user"
part = body["input"][0]["content"][0]
assert part["type"] == "input_text" and json.loads(part["text"])["question"] == dangerous
assert body["stream"] is False
assert ("store" not in body) if profile == "deepseek" else body["store"] is False
assert set(body).isdisjoint(
{
"messages",
"n",
"max_tokens",
"max_completion_tokens",
"previous_response_id",
"conversation",
"tools",
"api_key",
"base_url",
"temperature",
}
)
options.temperature = 0
assert responses_body("query", [], options)["temperature"] == 0
@pytest.fixture
def response_context(context):
context.completion = ResponsesOptions(protocol="responses", **OPTIONS)
return context
def model_response(answer="合成回答[E1]", status="completed"):
return {
"id": "resp_contract",
"object": "response",
"model": "contract-model-revision",
"status": status,
"error": None,
"incomplete_details": None,
"output": [
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "not an answer"}]},
{
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": answer, "annotations": []}],
},
],
"usage": {
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 15,
"input_tokens_details": {"cached_tokens": 4},
"output_tokens_details": {"reasoning_tokens": 3},
},
}
def test_responses_text_usage_and_absent_usage_are_preserved(response_context):
payload = model_response()
payload["output"][1]["content"] = [
{"type": "output_text", "text": "合成回答"},
{"type": "output_text", "text": "[E1] [E1]"},
]
result = parse_response(payload, response_context)
assert result.status == "answer_candidate" and result.citations == ["E1"]
assert result.answer == "合成回答[E1] [E1]" and result.response_id == "resp_contract"
assert result.model == "contract-model-revision"
assert result.usage.model_dump() == payload["usage"]
payload.pop("usage")
assert parse_response(payload, response_context).usage is None
assert (
parse_response(model_response(INSUFFICIENT), response_context).status
== "insufficient_evidence"
)
@pytest.mark.parametrize(
"reason", ["max_output_tokens", "max_messages", "steered", "content_filter"]
)
def test_responses_incomplete_never_returns_partial_answer(response_context, reason):
payload = model_response("partial[E1]", "incomplete")
payload["incomplete_details"] = {"reason": reason}
result = parse_response(payload, response_context)
assert result.status == ("refused" if reason == "content_filter" else "incomplete")
assert result.answer is None and result.citations == []
def test_responses_refusal_and_failed_are_not_answer_candidates(response_context):
payload = model_response()
payload["output"][1]["content"] = [{"type": "refusal", "refusal": "cannot answer"}]
assert parse_response(payload, response_context).status == "refused"
payload = model_response(status="failed")
payload["output"] = []
payload["error"] = {"code": "server_error", "message": "synthetic failure"}
result = parse_response(payload, response_context)
assert result.status == "failed" and result.answer is None
@pytest.mark.parametrize(
"change",
[
"object",
"unfinished",
"tool",
"unknown_item",
"reasoning_only",
"multiple",
"message_incomplete",
"bad_role",
"empty",
"no_citation",
"unknown_citation",
"bad_content",
"bad_usage",
"boolean_usage",
"bad_detail",
"bad_refusal",
"conflicting_error",
"missing",
"long",
],
)
def test_invalid_responses_never_become_answers(response_context, change):
payload = model_response()
message = payload["output"][1]
if change == "object":
payload["object"] = "chat.completion"
if change == "unfinished":
payload["status"] = "in_progress"
if change == "tool":
payload["output"].append({"type": "function_call"})
if change == "unknown_item":
payload["output"].append({"type": "other"})
if change == "reasoning_only":
payload["output"] = payload["output"][:1]
if change == "multiple":
payload["output"].append(copy.deepcopy(message))
if change == "message_incomplete":
message["status"] = "incomplete"
if change == "bad_role":
message["role"] = "user"
if change == "empty":
message["content"] = []
if change == "no_citation":
message["content"][0]["text"] = "unsupported claim"
if change == "unknown_citation":
message["content"][0]["text"] = "answer[E2]"
if change == "bad_content":
message["content"] = [{"type": "input_text", "text": "answer[E1]"}]
if change == "bad_usage":
payload["usage"]["total_tokens"] = 999
if change == "boolean_usage":
payload["usage"]["input_tokens"] = True
if change == "bad_detail":
payload["usage"]["output_tokens_details"]["reasoning_tokens"] = 99
if change == "bad_refusal":
message["content"] = [{"type": "refusal", "refusal": False}]
if change == "conflicting_error":
payload["error"] = {"code": "error"}
if change == "missing":
payload = {}
if change == "long":
message["content"][0]["text"] = "x" * 32001
with pytest.raises(CompletionProtocolError):
parse_response(payload, response_context)
def test_response_and_legacy_protocols_cannot_be_confused(context, response_context):
with pytest.raises(CompletionProtocolError):
parse_completion(completion(), response_context)
response_context.completion = CompletionOptions(**OPTIONS)
with pytest.raises(CompletionProtocolError):
parse_response(model_response(), response_context)
response_context.completion = ResponsesOptions(protocol="responses", **OPTIONS)
response_context.status = "insufficient_evidence"
response_context.evidence = []
with pytest.raises(CompletionProtocolError):
parse_response(model_response(), response_context)
+2 -2
View File
@@ -14,7 +14,7 @@ FastAPI + Pydantic
|-- 来源获取 -> 人工核验 -> 文本切分 -> 生成向量 -> 发布版本 |-- 来源获取 -> 人工核验 -> 文本切分 -> 生成向量 -> 发布版本
``` ```
当前后端实现健康/数据库就绪检查、来源与文档管理、知识增删改查、分页筛选、人工审核、历史快照、认证会话/角色权限、文本/向量快照及独立关键词/向量/RRF 检索;问答仍为占位接口。数据库由 Alembic 显式迁移并启用 pgvector,DeepSeek RAG 待明确恢复后实现。前端已接入登录/注册、个人中心、账户设置、会话恢复、路由守卫和角色菜单,以及来源、文档的创建、编辑、软删除、分页搜索与历史快照;实验页不伪造数据,知识审核与浏览器 Cookie/PostgreSQL 联调仍需验收。 当前后端实现健康/数据库就绪检查、来源与文档管理、知识增删改查、分页筛选、人工审核、历史快照、认证会话/角色权限、文本/向量快照及独立关键词/向量/RRF 检索;`/api/chat` 为独立条目关键词检索适配,返回检索状态与来源,不调用生成模型(见 [CHAT_API.md](CHAT_API.md))。数据库由 Alembic 显式迁移并启用 pgvector,DeepSeek RAG 待明确恢复后实现。前端已接入登录/注册、个人中心、账户设置、会话恢复、路由守卫和角色菜单,以及来源、文档的创建、编辑、软删除、分页搜索与历史快照;实验页不伪造数据,知识审核与浏览器 Cookie/PostgreSQL 联调仍需验收。
## 阶段 2 数据边界 ## 阶段 2 数据边界
@@ -60,6 +60,6 @@ FastAPI + Pydantic
## RAG 上下文与兼容请求准备(T08) ## RAG 上下文与兼容请求准备(T08)
`rag_contexts` 通过检索记录外键保存证据、出处/版本、选段/排除原因、提示词版本和 OpenAI 兼容 Chat Completions 请求体。准备时在发布锁内复核审核资料及文本/向量快照;已过期检索必须重查。输入与证据作为 JSON 数据放在 user message,固定规则独立保存,不允许客户端提交任意上下文或凭据。字符预算不是模型 token 校验。 `rag_contexts` 通过检索记录外键保存证据、出处/版本、选段/排除原因、提示词版本和 OpenAI 兼容 Responses 请求体(旧 Chat Completions 快照保留原样可读)。准备时在发布锁内复核审核资料及文本/向量快照;已过期检索必须重查。输入与证据作为 JSON 数据放在 Responses input 的 user 消息,固定规则独立保存,不允许客户端提交任意上下文或凭据。字符预算不是模型 token 校验。
准备/历史接口限管理员,写入要求会话 CSRF;历史保持当时快照。当前提供纯响应格式/引用解析,不调用生成模型;`prepared_request/model_called=false` 不能视为实际回答或领域实验。真实服务接入及模型 token 预算验证等待后续阶段。字段、权限、迁移和回滚见 [RAG_CONTEXT_API.md](RAG_CONTEXT_API.md)。 准备/历史接口限管理员,写入要求会话 CSRF;历史保持当时快照。当前提供纯响应格式/引用解析,不调用生成模型;`prepared_request/model_called=false` 不能视为实际回答或领域实验。真实服务接入及模型 token 预算验证等待后续阶段。字段、权限、迁移和回滚见 [RAG_CONTEXT_API.md](RAG_CONTEXT_API.md)。
+30
View File
@@ -0,0 +1,30 @@
# 检索式聊天接口
`POST /api/chat` 是已核验知识的条目级关键词检索适配,不调用生成模型。它与问答页面直接使用的 chunk/RRF 接口不同,也不会创建 Responses 准备快照。生成模型与真实服务接入继续延期。
## 请求
```json
{"question":"那拉提示例关键词"}
```
接口公开可用,无需登录;只接受 question。服务端去除首尾空白后校验1–500个字符,与底层 RetrievalRequest 共用同一字段约束;字符数不代表模型token数。缺失、空白、错误类型、超长输入或额外字段(包括 model、messages、凭据)均返回422,不写检索记录。只有停用词或标点、没有有效检索词时同样返回422。错误不包含原始输入值。
原适配曾允许2000字符,内层检索只允许500字符,导致501–2000字符请求返回500。现在该范围在HTTP入参处明确返回422,不截断问题,也不绕过原检索限制。
## 响应与证据
成功返回 answer、status、sources 和 retrieval_run_id。answer 为固定的检索状态说明,**不是生成式回答**。
- status=retrieval_only:返回最多5条来源预览(title/source_url),来自同一条目关键词检索历史的排名结果。说明中的命中总数可以大于来源预览数;完整排名、正文片段、来源/知识版本、配置、语料指纹和耗时保存在原检索记录中。
- status=no_evidence:没有命中的有效已核验依据,sources为空;仍保存该次真实无命中检索的run ID。
待审核、拒绝、软删除、来源/文档失效或修订后等待重新审核的条目不作为来源。资料修订影响后续请求,不重写已经保存的历史快照。没有LLM调用量、生成答案或费用字段,不能把固定说明用于领域质量评分。
检索历史及JSONL导出仍要求管理员权限;公开获取run ID不授予历史读取权限。该run的mode=keyword、证据粒度为knowledge_item,不能传入只接收chunk检索的 `POST /api/rag/contexts`。需要Responses准备时先使用chunks/vector/hybrid检索。
容量超限/数据库不可用保留503,历史保存约束冲突返回409;错误不会伪装成200无命中,也不会保存部分历史。仅成功完成检索与历史事务后返回来源。无额外数据库迁移。
## 验证边界
测试使用合成条目、真实隔离PostgreSQL和默认应用HTTP生命周期,覆盖500字符边界、501字符HTTP失败复现与修正、参数拒绝不写记录、公开调用与历史权限、审核筛选、资料修订/历史不变、容量与事务失败,以及进程重启后的历史和导出。没有模型调用或新增浏览器验收。
+59
View File
@@ -0,0 +1,59 @@
# 2026-10-17 导出与采集时间浏览器验收
本轮基于已合入的检索导出、导出工作台和资料生命周期页面,在隔离 PostgreSQL/pgvector 与默认生命周期 FastAPI、Vite、真实浏览器之间联调。全部账号、资料和查询均为合成测试;没有加载 Embedding、调用生成模型或产生领域实验结论。
## 基线与准备
- 旧 AGit [PR #33](https://gitea.kronecker.cc/saint/border-red-post-nalati/pulls/33) 关闭未合并,其普通分支通过真正 merged 的 [PR #34](https://gitea.kronecker.cc/saint/border-red-post-nalati/pulls/34) 合入 dev,保留原实现并增加下载响应的显式 `Cache-Control: no-store`。原 head、替代 head 和 merge 均已核对为最新 dev 祖先。
- [PR #36](https://gitea.kronecker.cc/saint/border-red-post-nalati/pulls/36) 的导出页面和 [PR #38](https://gitea.kronecker.cc/saint/border-red-post-nalati/pulls/38) 的来源生命周期页面也已合入。从最新 dev 创建本阶段分支,没有叠加待审功能或合入 main。
- 独立 HTTP 初始化合成来源、带微秒采集时间的关联文档、已审核知识和文本发布,并实际执行 22 条条目/chunk 关键词检索,准备其中一条的兼容请求上下文。这些是测试准备,不能称为浏览器录入或向量推理。
## 浏览器已实际验证的路径
| 路径 | 结果 |
|---|---|
| 匿名访问导出页 | 跳转登录并保留回跳路径;管理员登录后回到导出页 |
| 历史分页 | 显示 22 条,第一页 20 条、第二页 2 条;两端翻页按钮正确禁用 |
| 跨页选择 | 第一页选择一条 chunk 记录,第二页选择一条条目记录,仍保留两条选择 |
| 可选上下文 | 勾选关联 RAG 准备上下文后触发真实浏览器文件下载 |
| 下载文件核对 | 文件实际落盘;manifest 为 2 条检索和 1 条上下文;记录区 SHA-256 与页面显示一致,每个 payload 与原保存快照一致 |
| 文档时区缺陷复现 | 原采集时间为 `2026-10-16T12:34:56.789123+08:00`,仅修改备注却变成 `2026-10-16T04:34:00+08:00` |
| 修正后时间回填 | 编辑页显示当地时钟的 `12:34:56.789`,而非 UTC 时钟的 `04:34`;新建表单采集时间留空,要求明确填写 |
| 修正后仅修改备注 | 页面保存成功;独立 API 读取确认原带微秒的时间字符串完全保留,文档版本递增 |
| 文档历史 | 页面展开最新版本快照,可看到版本 4 及原 `12:34:56.789123+08:00` 采集时间 |
浏览器实际下载:
![跨页选择并下载](assets/export-acceptance/export-download.jpg)
备注修改后的历史快照:
![采集时间保留原微秒值](assets/export-acceptance/document-collected-at.jpg)
## 修正内容
原表单把 `toISOString()` 的 UTC 时钟直接填入 `datetime-local`,提交时又按本地时区解析,造成时区偏移;截取到分钟还会丢失秒和更高精度。
新增日期转换函数:回填使用本地时钟,支持秒/毫秒输入;若管理员没有修改时间,直接保留服务端原时间字符串,避免丢失微秒或在夏令时重叠时刻改变实际瞬间。明确修改时间时才转换为带时区的 ISO 时间。新建文档不再预填当前时间作为采集事实,仍由必填校验要求输入。 最新 dev 新合入的知识编辑页使用了相同的 UTC 截取方式,本轮同步复用此转换函数,保留其无采集时间时的空值语义;该知识页面的修改通过共享函数回归和构建验证,没有冒充新的浏览器操作。
为复核修正效果,缺陷复现后只在隔离合成数据上通过独立 HTTP 恢复原采集时间,再重新加载页面执行仅改备注的浏览器验证。该恢复属于测试基线准备,没有冒充浏览器操作;不自动修复或猜测既有业务资料的真实采集时间。
## 独立 HTTP 交叉核对
以下和浏览器操作分开记录:
- 文档修改后关联知识回到 pending,旧文本发布的检索返回 409;没有继续使用旧审核依据。
- 被活动知识/文档引用的文档、来源删除返回 409;过期版本修改返回 409。
- 单独可丢弃来源/文档经过修改和软删除后,正文详情隐藏,删除历史快照保留。
- 来源与文档修订后再次通过 HTTP 导出原选择,记录区原始字节与首次浏览器下载完全一致;manifest 的导出时间/编号允许变化,生成用量/费用仍未采集。
- 下载响应 `Cache-Control: no-store`;普通用户直接请求导出返回 403。
## 验证命令和边界
- `npm run test:datetime`:**6 passed**,覆盖中国时区、UTC、负偏移跨日、夏令时重叠、显式修改、微秒与空值;这些是 Node 时区单元测试,不等同于多浏览器验收。
- `npm run test:session`:**3 passed**;`npm run build`(含类型检查):通过。
- `pytest -q backend/tests/test_exports.py backend/tests/test_catalog.py`:**49 passed**;`ruff check backend`、`ruff format --check backend`:通过。本轮未把此前全量 254 项结果当作本阶段重新执行的结果。
- 原生删除确认框使本次浏览器控制无法继续完成确认;该浏览器删除路径未验收。相关删除保护、软删除与历史保留仅由上述独立 HTTP 和后端专项覆盖,仍需人工复核确认交互。没有据此放宽删除保护或修改确认机制。
- Docker、生产 HTTPS、多浏览器、浏览器改密、领域资料/问题集及生成模型实际调用仍不在本轮已完成范围。
复现时使用单独数据库或隔离 Schema、合成账号和明确的采集时间,按上述步骤比较下载原始字节及修改前后时间;环境、密码、原始 JSONL、数据库、日志和本机路径不入库。验证结束后关闭测试服务并删除本轮隔离 Schema。截图只保留合成数据,导出下载文件和原始检查记录保存在 Git 元数据目录。
+32 -17
View File
@@ -1,8 +1,8 @@
# RAG 证据与 OpenAI 兼容请求准备 # RAG 证据与 OpenAI 兼容请求准备
本阶段按用户要求先实现通用请求格式,生成模型选择和真实调用延期。协议采用非流式 Chat Completions:`model/messages`、单条 assistant 响应、可选用量;不绑定供应商、服务地址、密钥或具体模型。字段依据 [OpenAI 官方 Chat Completions 文档](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)。 本阶段按用户要求先实现通用请求格式,生成模型选择和真实调用延期。新准备请求采用非流式 **Responses API**(服务端点为 `<base_url>/responses`):`model/instructions/input/max_output_tokens`,从结构化 `output` 读取 assistant 文本。依据 [OpenAI Responses 迁移说明](https://developers.openai.com/api/docs/guides/migrate-to-responses)、[DeepSeek Responses 兼容表](https://api-docs.deepseek.com/guides/responses_api/) 与[百炼 Responses 文档](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-responses)。当前仍为纯组包和解析,不绑定实际服务地址、密钥或模型,不发出 HTTP 生成请求。
本阶段提供管理员上下文准备与历史接口、完整证据快照、固定提示词及纯响应解析器。`POST /api/chat` 仍保留原占位状态,尚未成为生成式问答接口;准备记录固定为 `kind=prepared_request, model_called=false`,不能作为实际模型回答、耗时、费用或领域实验结果。 本阶段提供管理员上下文准备与历史接口、完整证据快照、固定提示词及纯响应解析器。`POST /api/chat` 是独立的条目级关键词检索适配,返回检索状态和来源,尚未成为生成式问答接口(见 [CHAT_API.md](CHAT_API.md));准备记录固定为 `kind=prepared_request, model_called=false`,不能作为实际模型回答、耗时、费用或领域实验结果。
## 准备与读取 ## 准备与读取
@@ -18,10 +18,10 @@
{ {
"retrieval_run_id": "00000000-0000-0000-0000-000000000001", "retrieval_run_id": "00000000-0000-0000-0000-000000000001",
"completion": { "completion": {
"protocol": "responses",
"provider_profile": "deepseek",
"model": "deployment-model-id", "model": "deployment-model-id",
"output_tokens": 512, "output_tokens": 512
"token_parameter": "max_completion_tokens",
"instruction_role": "system"
}, },
"max_input_characters": 10000, "max_input_characters": 10000,
"max_evidence": 5 "max_evidence": 5
@@ -33,14 +33,22 @@
| 字段 | 约束 | | 字段 | 约束 |
|---|---| |---|---|
| `completion.model` | 必填,1–128 个字母/数字及 `._:/-`;只保存标识,不验证模型存在或服务可用 | | `completion.model` | 必填,1–128 个字母/数字及 `._:/-`;只保存标识,不验证模型存在或服务可用 |
| `output_tokens` | 默认 512,1–4096,写入所选输出预算字段 | | `protocol` | 新准备只接受 `responses`;省略时补为 `responses`,保存的快照必须明确记录协议 |
| `token_parameter` | 默认 `max_completion_tokens`;显式选择 `max_tokens` 才生成旧兼容字段,两者不同时发送 | | `provider_profile` | 默认 `compatible`;可选 `openai/deepseek/bailian`,只控制已实现的字段差异,不代表已选择模型供应商 |
| `instruction_role` | 默认 `system`,也可显式使用 `developer`;须由后续实际服务兼容性测试确认 | | `output_tokens` | 默认 512,16–4096,映射为 `max_output_tokens` |
| `temperature` | 可选 0–2;省略时请求体不发送,不预设所有模型都支持该参数 | | `temperature` | 可选 0–2;百炼档案拒绝 2(官方范围为 [0,2));省略时不发送。仍须按具体模型检查采样参数是否生效 |
| `max_input_characters` | 默认 10000,1024–24000;两个 message 的 content 字符数之和 | | `max_input_characters` | 默认 10000,1024–24000;instructions 与 input_text 的字符数之和 |
| `max_evidence` | 默认 5,1–10;保持检索排名顺序挑选证据 | | `max_evidence` | 默认 5,1–10;保持检索排名顺序挑选证据 |
组装的请求体为 `model`、两个文本 `messages`、`stream=false`、`n=1`、`store=false`、所选输出预算字段,及显式提供的 temperature。第一条消息只有固定规则,第二条是规范 JSON 的 `question/evidence` 数据。请求不包含鉴权或服务地址。这是明确的协议子集,不保证每个“OpenAI 兼容”服务都支持所有字段;后续接入须核对服务的角色、参数及模型限制,不做静默重试或字段回退。 新请求固定发送 `model/instructions/input/max_output_tokens/stream=false`,显式提供时才加入 temperature。instructions 只含固定规则;input 中只有一条 user 消息,其 `input_text` 是规范 JSON 的 `question/evidence` 数据。不会发送 Chat Completions 的 `messages/n/max_tokens/max_completion_tokens`,也不发送工具、会话 ID、鉴权或服务地址。
| 档案 | 当前处理 | 后续真实接入边界 |
|---|---|---|
| `compatible/openai` | 显式 `store=false` | 兼容服务仍须确认支持该字段及所选模型参数 |
| `deepseek` | 省略不受支持的 store;官方服务固定无状态 | 不依赖 `previous_response_id/conversation`;不把 developer 角色当通用系统规则 |
| `bailian` | 显式 `store=false`;temperature 小于 2 | 百炼工具、token 上限和截断行为依模型而异 |
该子集不实现工具、流式 SSE、服务端会话链或特定模型的推理控制,也不宣称所有国内模型均可用。服务地址和凭据在真实 HTTP 客户端阶段从服务端配置读取,当前准备 API 不接受 URL 或密钥。无协议自动探测、静默切换或付费重试。旧的 `token_parameter/instruction_role` 输入返回 422,调用方应删除这些 Chat Completions 专用选项。只传 `completion.model` 的原调用会默认生成 Responses 包。
字符预算用于有限体积的准备工作,**不是模型 token 数或上下文窗口校验**。输出 token 上限同样不表示输入能被某个尚未选定的模型接受。真实发送前须按所选模型验证完整输入与输出预算,超限明确失败,不静默截断原文。 字符预算用于有限体积的准备工作,**不是模型 token 数或上下文窗口校验**。输出 token 上限同样不表示输入能被某个尚未选定的模型接受。真实发送前须按所选模型验证完整输入与输出预算,超限明确失败,不静默截断原文。
@@ -48,7 +56,7 @@
准备时只从数据库读取检索结果,并重新核对当前有效审核语料、原文 chunks、文本发布 ID/代数、目录修订和语料指纹。vector/hybrid 还须匹配当前向量发布 ID/代数、完整向量集及空间身份。整个检查至保存过程沿用目录/发布锁,不重新编码、不加载 Embedding,也不调用生成模型。知识变化、文本或向量重新发布、缺失向量/原文、出处或检索记录不一致均返回 409,要求重新检索。 准备时只从数据库读取检索结果,并重新核对当前有效审核语料、原文 chunks、文本发布 ID/代数、目录修订和语料指纹。vector/hybrid 还须匹配当前向量发布 ID/代数、完整向量集及空间身份。整个检查至保存过程沿用目录/发布锁,不重新编码、不加载 Embedding,也不调用生成模型。知识变化、文本或向量重新发布、缺失向量/原文、出处或检索记录不一致均返回 409,要求重新检索。
按检索排名逐条处理完整 chunk,给被纳入的证据分配连续 `E1/E2/...`。保存知识/文档/来源 ID 与版本、来源 URL、标题/分类、chunk ID/序号、原文、字符偏移和 SHA-256、检索排名。对同一知识已选择片段的重叠区间排除后续片段;达到证据数上限或整体 message 字符预算时,排除相应片段并继续检查后续可容纳候选,不切割原文。 按检索排名逐条处理完整 chunk,给被纳入的证据分配连续 `E1/E2/...`。保存知识/文档/来源 ID 与版本、来源 URL、标题/分类、chunk ID/序号、原文、字符偏移和 SHA-256、检索排名。对同一知识已选择片段的重叠区间排除后续片段;达到证据数上限或整体 instructions/input 字符预算时,排除相应片段并继续检查后续可容纳候选,不切割原文。
每个排除项记录 chunk ID 及 `overlap/budget/evidence_limit` 原因。没有候选时返回 `insufficient_evidence/no_candidates`;全部候选超出预算时为 `insufficient_evidence/context_budget`。这两种情况均不生成 request_body,不能把空证据包交给后续模型。 每个排除项记录 chunk ID 及 `overlap/budget/evidence_limit` 原因。没有候选时返回 `insufficient_evidence/no_candidates`;全部候选超出预算时为 `insufficient_evidence/context_budget`。这两种情况均不生成 request_body,不能把空证据包交给后续模型。
@@ -67,17 +75,24 @@
## 兼容响应解析 ## 兼容响应解析
`services/completions.py` 提供纯解析函数,当前不暴露可向数据库写入“模型回答”的导入接口,也没有 HTTP 模型客户端。它只接受 `object=chat.completion` 的单一 index=0 assistant 文本候选,拒绝流式 chunk、工具/函数调用、未知结束原因或畸形结构。 `services/responses.py` 提供纯 `parse_response`;不暴露回答写入接口,也没有 HTTP 模型客户端。只接受 `object=response` 的最终响应,区分顶层状态、reasoning 和 message 输出项,不读取 SDK 便利字段 `output_text` 来代替原始 output 校验。
- `finish_reason=length` 标记 incomplete 并丢弃部分答案;content_filter 或 refusal 标记 refused。 - `completed` 必须恰有一条 completed assistant message,可按顺序连接多个 output_text 片段;reasoning 不作为答案,工具和未知输出类型拒绝。
- 正常 stop 响应必须有非空文本、至少一个已提供的 `[E#]` 引用;缺失、越界或错误编号拒绝。固定不足说明单独标记 insufficient_evidence。 - `incomplete` 丢弃部分文本;content_filter 或 refusal 标记 refused;`failed` 标记 failed,不作为候选。in_progress/queued、冲突状态及畸形结构拒绝。
- 返回实际 response ID/model、引用列表和可选非负整数 token 用量;用量合计不一致时拒绝,不自行估算费用。 - 正常文本必须有已提供的 `[E#]` 引用;无引用、越界、空文本、超过32000字符均拒绝。固定不足说明标记 insufficient_evidence。
- 即使引用结构通过,也只标记 `answer_candidate`。引用存在不能证明原文支持每个事实,事实支持度、冲突、时效及人工验收仍需后续阶段验证。 - 返回实际 `response_id/model` 和可选 `input_tokens/output_tokens/total_tokens`;保留缓存与推理 token 明细,校验非负整数、总数和明细上限。用量缺失保留 null,不估算费用。
- 引用结构通过只表示 `answer_candidate`,不证明事实支持度、冲突处理或时效正确。
`services/completions.py` 仅保留旧快照的组包/解析,用于重建校验与读取历史;Responses 解析与旧协议解析不能混用,不据此继续创建新 Chat Completions 准备记录。
测试使用明确的合成响应/坐标与 PostgreSQL,不冒充真实模型推理。供应商兼容、生成质量、真实 token 预算、超时/重试/限流和费用记录须在模型选择后另行验收。 测试使用明确的合成响应/坐标与 PostgreSQL,不冒充真实模型推理。供应商兼容、生成质量、真实 token 预算、超时/重试/限流和费用记录须在模型选择后另行验收。
## 迁移、回滚与交接 ## 迁移、回滚与交接
Responses 调整没有新增数据库迁移,仍为 `0007_rag_contexts`。新 completion 配置增加 protocol/provider_profile;旧快照无 protocol 时严格按原 Chat Completions 格式读取。旧请求体、配置、SHA-256 和 JSONL 记录原样保留;导出按每份快照的协议重建核对,不因新默认值重写旧数据。同一次导出可包含两种协议,现有 manifest/记录结构无需变化。
回滚 Responses 代码前须备份新旧准备记录;此前只认识 Chat Completions 的后端无法保证读取新 Responses 快照。优先保留本版双协议历史读取,或恢复完整备份,不通过清空历史、删除协议字段或执行数据库降级伪装兼容。下述表迁移说明属于最初上下文阶段,与本次纯协议变更不同。
新增 `0007_rag_contexts`,启动前备份并执行 `alembic upgrade head`。表通过外键保留检索记录和创建账号关系,只有追加与查询 API,没有修改/删除 API。撤回本阶段时可备份后配合旧后端执行 `alembic downgrade 0006_vectors`;仅删除准备快照表,保留知识、账号、文本/向量发布与检索历史。恢复准备快照须使用备份。 新增 `0007_rag_contexts`,启动前备份并执行 `alembic upgrade head`。表通过外键保留检索记录和创建账号关系,只有追加与查询 API,没有修改/删除 API。撤回本阶段时可备份后配合旧后端执行 `alembic downgrade 0006_vectors`;仅删除准备快照表,保留知识、账号、文本/向量发布与检索历史。恢复准备快照须使用备份。
前端新增 `services/rag.ts` 调用与类型,不修改现有问答页面。实际部署环境、运行日志、凭据和真实请求数据不得提交仓库。生成模型接入按用户后续选择推进,当前没有待创建的密钥,也不因延期而反复询问。 前端新增 `services/rag.ts` 调用与类型,不修改现有问答页面。实际部署环境、运行日志、凭据和真实请求数据不得提交仓库。生成模型接入按用户后续选择推进,当前没有待创建的密钥,也不因延期而反复询问。
+6 -1
View File
@@ -88,10 +88,15 @@ rows = [json.loads(line) for line in records.rstrip(b"\n").split(b"\n")]
assert len(rows) == sum(manifest["counts"].values()) assert len(rows) == sum(manifest["counts"].values())
``` ```
前端调用层 `services/exports.ts` 的 `exportRetrievalHistory` 返回 Blob、受限文件名和记录摘要,沿用同一 Cookie/CSRF、401 撤销及错误处理,不放入 localStorage。此阶段没有新增下载页面,页面入口由后续前端集成;调用层测试是模拟网络的契约测试,不冒充浏览器下载验收。 前端调用层 `services/exports.ts` 的 `exportRetrievalHistory` 返回 Blob、受限文件名和记录摘要,沿用同一 Cookie/CSRF、401 撤销及错误处理,不放入 localStorage。下载页面已通过 PR #36 合入,可从管理员工作台进入。真实浏览器跨页选择、可选上下文、落盘文件与摘要核对见 [导出浏览器验收](EXPORT_BROWSER_ACCEPTANCE.md);调用层模拟网络测试与真实浏览器路径分别记录。
## 验证与撤回 ## 验证与撤回
新增专项覆盖混合模式原始快照、可选准备记录、无命中、软删除后历史、摘要稳定性、权限/CSRF、未知字段/关联损坏拒绝、字节/条数上限、并发新增上下文以及真实 HTTP 后端重启后的同一记录区。坐标 fixture 仅用于导出协议,不构成真实领域模型实验。专项命令 `pytest -q backend/tests/test_exports.py`:**30 passed**;完整 `pytest -q backend/tests`:**254 passed, 1 skipped**,skip 为显式启用的真实 Embedding 用例,本阶段没有重跑模型。`ruff check backend`、`ruff format --check backend` 通过;前端在 `frontend/` 执行 `npm run test:session`:**3 passed**,`npm run build` 通过。 新增专项覆盖混合模式原始快照、可选准备记录、无命中、软删除后历史、摘要稳定性、权限/CSRF、未知字段/关联损坏拒绝、字节/条数上限、并发新增上下文以及真实 HTTP 后端重启后的同一记录区。坐标 fixture 仅用于导出协议,不构成真实领域模型实验。专项命令 `pytest -q backend/tests/test_exports.py`:**30 passed**;完整 `pytest -q backend/tests`:**254 passed, 1 skipped**,skip 为显式启用的真实 Embedding 用例,本阶段没有重跑模型。`ruff check backend`、`ruff format --check backend` 通过;前端在 `frontend/` 执行 `npm run test:session`:**3 passed**,`npm run build` 通过。
数据库不新增迁移,继续为 `0007_rag_contexts`。撤回导出路由、服务、调用层即可停止新下载;原检索/上下文历史与已保存文件不变,不为回滚删除数据库记录。 数据库不新增迁移,继续为 `0007_rag_contexts`。撤回导出路由、服务、调用层即可停止新下载;原检索/上下文历史与已保存文件不变,不为回滚删除数据库记录。
### Responses 与旧准备快照
新准备记录的 `completion.protocol=responses`,导出按 Responses 重建请求体并核对哈希;旧记录没有 protocol,继续按原 Chat Completions 配置重建。读取与导出不补写旧字段,不改变旧 payload 或记录字节;同一文件可包含两种协议。该变化仍不代表生成模型已调用,manifest 的生成用量和费用继续为未采集。
+29 -2
View File
@@ -11,9 +11,9 @@
| T05 | 知识库 | 设计 sources/documents/chunks/version 表 | 范涵宇 | 已完成(迁移、发布快照和接口已合入;待 Docker 联调) | | T05 | 知识库 | 设计 sources/documents/chunks/version 表 | 范涵宇 | 已完成(迁移、发布快照和接口已合入;待 Docker 联调) |
| T06 | 检索 | 实现关键词检索基线 | 范涵宇 | 已完成(已核验知识 BM25 与检索历史已合入) | | T06 | 检索 | 实现关键词检索基线 | 范涵宇 | 已完成(已核验知识 BM25 与检索历史已合入) |
| T07 | 向量 | 评估 Embedding 并实现 pgvector | 范涵宇 | 已完成(本地模型、精确向量和 RRF 已合入;待真实领域评测) | | T07 | 向量 | 评估 Embedding 并实现 pgvector | 范涵宇 | 已完成(本地模型、精确向量和 RRF 已合入;待真实领域评测) |
| T08 | RAG | 集成 LLM 与来源展示 | 范涵宇 | 协议已合入(证据与 OpenAI 兼容请求契约已完成;生成模型选型与实际调用按要求延期) | | T08 | RAG | 集成 LLM 与来源展示 | 范涵宇 | Responses 协议已合入(请求/解析与旧快照兼容已完成;生成模型选型与实际调用延期) |
| T09 | 实验 | 构建可核验问题集及评分规范 | 杨星萱 | 待办 | | T09 | 实验 | 构建可核验问题集及评分规范 | 杨星萱 | 待办 |
| T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(迁移、认证、CSRF、224项回归及浏览器核心流程已通过;Docker/HTTPS/部署待验收) | | T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(迁移、认证、CSRF及浏览器核心流程已通过;聊天边界修正和312项分支回归待审,Docker/HTTPS/部署待验收) |
## 2026-10-15 首次联调记录 ## 2026-10-15 首次联调记录
@@ -107,3 +107,30 @@
- 一个 SQL 语句读取历史快照,保留旧版本而不重新检索;导出前完整校验,最多100条检索/100条上下文/8MiB,不静默截断,不返回部分文件。不新增数据库迁移或环境配置。 - 一个 SQL 语句读取历史快照,保留旧版本而不重新检索;导出前完整校验,最多100条检索/100条上下文/8MiB,不静默截断,不返回部分文件。不新增数据库迁移或环境配置。
- 前端仅增加下载类型与调用层,复用 Cookie/CSRF/错误处理,不新增页面;真实浏览器下载与实验展示留给后续集成。完整字段、摘要核对、复现与回滚见 [RETRIEVAL_EXPORT_API.md](RETRIEVAL_EXPORT_API.md)。 - 前端仅增加下载类型与调用层,复用 Cookie/CSRF/错误处理,不新增页面;真实浏览器下载与实验展示留给后续集成。完整字段、摘要核对、复现与回滚见 [RETRIEVAL_EXPORT_API.md](RETRIEVAL_EXPORT_API.md)。
- 专项测试 **30 passed**,包括真实 PostgreSQL 并发快照和 HTTP 重启导出;完整回归 **254 passed, 1 skipped**(skip 为须显式启用的真实 Embedding 专项),`ruff check` 与 `ruff format --check` 通过。前端会话下载契约 **3 passed**,类型与生产构建通过。测试使用合成资料与坐标 fixture,未重跑模型推理或调用生成模型。 - 专项测试 **30 passed**,包括真实 PostgreSQL 并发快照和 HTTP 重启导出;完整回归 **254 passed, 1 skipped**(skip 为须显式启用的真实 Embedding 专项),`ruff check` 与 `ruff format --check` 通过。前端会话下载契约 **3 passed**,类型与生产构建通过。测试使用合成资料与坐标 fixture,未重跑模型推理或调用生成模型。
## 2026-10-17 T05/T10:导出与采集时间浏览器复核
- 导出原实现及显式禁用缓存修正已通过替代 PR #34 真正合入 dev,原 #33 关闭未合并;已核对原 head、当前普通分支 head 与 merge 均包含在最新 dev。导出工作台 #36 与来源生命周期 #38 已合入,再创建唯一阶段分支 `test/backend-export-acceptance`。
- 真实浏览器完成匿名回跳/管理员登录、22条历史两页跨页选择、关联准备上下文与实际 JSONL 下载;落盘文件为2条检索/1条上下文,记录区摘要与页面一致,payload 与保存的原始快照一致。本轮没有模型推理。
- 发现并修正仅改文档备注时采集时间偏移8小时且截断到分钟的问题;回填使用本地时钟,未改时间保留服务端原微秒值,新建不猜测采集时间。最新合入的知识编辑页复用同一修正。文档浏览器保存与历史快照、独立API均复核通过;知识页仅共享函数测试和构建验证。
- 前端时区6项、会话3项测试与构建通过;后端导出/目录专项49项和ruff检查/格式检查通过。来源/文档版本、引用删除保护、软删除历史及修订后原导出字节不变均经独立HTTP核对。详见 [EXPORT_BROWSER_ACCEPTANCE.md](EXPORT_BROWSER_ACCEPTANCE.md)。
- 浏览器原生删除确认交互受工具限制仍待人工复核,不把独立HTTP删除验证当作浏览器结果;Docker/HTTPS/多浏览器/浏览器改密、领域资料与生成调用继续保留待办。
## 2026-10-17 T08:Responses 协议调整
- 从最新 dev 创建独立 `feat/backend-responses-protocol`,协议开发不依赖 PR #44;随后同步了已合入的采集时间/浏览器修正。新准备默认使用 Responses:固定 instructions 与 user input_text、max_output_tokens、非流式 output 解析;模型和真实服务接入继续延期。
- 新配置明确 protocol/provider_profile;提供 compatible、openai、deepseek、bailian 档案。DeepSeek 省略不受支持的 store,其他档案显式 store=false;百炼限制 temperature 小于2。旧 Chat Completions 参数拒绝,不自动探测、换协议或重试。档案通过离线契约验证,不代表已对真实供应商调用或通过全部模型能力验证。
- 解析区分 completed/incomplete/refused/failed,reasoning 不充当答案;拒绝工具、异常状态和错误引用。保存实际模型标识及可选输入/输出、缓存/推理 token 用量,不估算费用。字符预算仍不是模型 token 预算。
- 保留旧准备快照的完整配置、请求体、哈希与 JSONL 字节;两种协议可混合读取/导出。API路径与迁移不变,仍为0007;新请求使用 Responses,旧协议仅用于历史校验。字段、厂商差异、范围和回滚限制见 [RAG_CONTEXT_API.md](RAG_CONTEXT_API.md)。
- 本轮 `pytest -q backend/tests/test_rag.py backend/tests/test_exports.py` **113 passed**;完整 `pytest -q backend/tests` **294 passed, 1 skipped**。skip 为显式启用的真实 Embedding 专项,本轮不运行模型。ruff检查/格式检查及前端类型/生产构建通过;未修改页面交互,不宣称新增浏览器验收。
- PR #44 合入后同步最新 dev,保留两份阶段记录并解决本文件追加冲突。此次同步后重新执行前端时区6项、会话3项及类型/生产构建,均通过;backend树与此前294项回归时一致,本次未重复运行全量后端测试,也未新增模型调用或浏览器验收。
## 2026-10-17 T10:检索式聊天边界与后端验收
- PR #45 的 head/merge 均已核对进入 dev,再从最新 dev 创建 `fix/backend-chat-validation`。新合入 PR #46 已实现 `/api/chat` 的检索式适配,本轮补上后端边界修正和集成验收,不重复实现检索、不接入生成模型。
- 已复现501字符问题返回500:外层ChatRequest允许2000字符,内层RetrievalRequest只允许500字符。现在两者共用QueryText与输入规范,问题去首尾空白后1–500字符,超长/空白/错误类型/额外字段在HTTP边界返回422,不静默截断,也不写检索历史。
- 补充有效审核来源筛选、资料修订后重新审核、公开聊天/私有历史权限、历史与导出持久化、容量503及写入409完整回滚验证;真实HTTP重启后检索历史保持一致。Chat仅生成固定检索状态说明,条目级记录不能传给chunk级Responses准备。完整契约见 [CHAT_API.md](CHAT_API.md)。
- 专项 `pytest -q backend/tests/test_chat.py backend/tests/test_retrieval.py` **35 passed**;完整 `pytest -q backend/tests` **312 passed, 1 skipped**,skip为显式启用的真实Embedding专项,本轮未运行模型。ruff检查/格式检查、diff检查和最新前端类型/生产构建通过。API路径、响应字段和数据库迁移不变;README/架构文档更正已过期的聊天占位说明。无新增浏览器验收或领域分数。
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test:session": "node --test tests/session.test.mjs" "test:session": "node --test tests/session.test.mjs",
"test:datetime": "node --test tests/datetime.test.mjs"
}, },
"dependencies": { "dependencies": {
"lucide-vue-next": "^0.468.0", "lucide-vue-next": "^0.468.0",
+2 -1
View File
@@ -4,6 +4,7 @@ import { Activity, BookOpen, Bot, CheckCircle2, ChevronRight, Database, FileText
import { createKnowledge, createSource, getHealth, getKnowledge, getSources, type SourceItem } from './api' import { createKnowledge, createSource, getHealth, getKnowledge, getSources, type SourceItem } from './api'
import { retrieveHybrid } from './services/hybrid' import { retrieveHybrid } from './services/hybrid'
import { getCurrentUser } from './services/auth' import { getCurrentUser } from './services/auth'
import { saveLocalHistory } from './services/localHistory'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { SessionUser } from './types/auth' import type { SessionUser } from './types/auth'
type Page='chat'|'knowledge'|'sources'|'evaluation' type Page='chat'|'knowledge'|'sources'|'evaluation'
@@ -20,7 +21,7 @@ async function saveSource(){error.value='';notice.value='';if(!sourceForm.value.
let sourceSearchTimer:number|undefined let sourceSearchTimer:number|undefined
watch(sourceSearch,()=>{window.clearTimeout(sourceSearchTimer);sourceSearchTimer=window.setTimeout(refreshSources,260)}) 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()])}) 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}} function validUrl(value:string){try{if(value)new URL(value);return true}catch{return false}}
async function save(){ async function save(){
error.value='';notice.value='' error.value='';notice.value=''
+6 -1
View File
@@ -3,11 +3,16 @@ import type { VectorRetrievalResult } from './services/vectors'
import type { ChunkKeywordResult, HybridResult } from './services/hybrid' import type { ChunkKeywordResult, HybridResult } from './services/hybrid'
export const getHealth = () => apiRequest<{status:string}>('/api/health') 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 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) 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)
export type VerificationStatus='pending'|'verified'|'rejected' export type VerificationStatus='pending'|'verified'|'rejected'
export type KnowledgeItem={id:number;version:number;title:string;category:string;content:string;source_url:string|null;source_id:number|null;document_id:number|null;collected_at:string|null;verification_status:VerificationStatus;reviewer:string|null;reviewed_at:string|null;verification_notes:string|null;created_at:string;updated_at:string;is_deleted:boolean}
export function getKnowledgePage(filters:{q?:string;category?:string;verification_status?:VerificationStatus;page?:number;page_size?:number}={}){const params=new URLSearchParams();for(const [key,value] of Object.entries(filters)){if(value!==undefined&&value!=='')params.set(key,String(value))}return apiRequest<{items:KnowledgeItem[];total:number;page:number;page_size:number}>(`/api/knowledge/page?${params}`)}
export function updateKnowledge(id:number,payload:{expected_version:number;title?:string;category?:string;content?:string;source_url?:string|null;collected_at?:string|null}){return apiRequest<KnowledgeItem>(`/api/knowledge/${id}`,{method:'PATCH',body:JSON.stringify(payload)},true)}
export function deleteKnowledge(id:number,expectedVersion:number){return apiRequest<void>(`/api/knowledge/${id}?expected_version=${expectedVersion}`,{method:'DELETE'},true)}
export function getKnowledgeHistory(id:number,page=1,pageSize=20){return apiRequest<{items:AuditItem[];total:number;page:number;page_size:number}>(`/api/knowledge/${id}/history?page=${page}&page_size=${pageSize}`)}
export type SourceItem={id:number;version:number;name:string;source_url:string|null;notes:string;created_at:string} export type SourceItem={id:number;version:number;name:string;source_url:string|null;notes:string;created_at:string}
export function getSources(q='',page=1,pageSize=20){return apiRequest<{items:SourceItem[];total:number;page:number;page_size:number}>(`/api/sources?q=${encodeURIComponent(q)}&page=${page}&page_size=${pageSize}`)} export function getSources(q='',page=1,pageSize=20){return apiRequest<{items:SourceItem[];total:number;page:number;page_size:number}>(`/api/sources?q=${encodeURIComponent(q)}&page=${page}&page_size=${pageSize}`)}
export function createSource(payload:{name:string;source_url?:string;notes:string}){return apiRequest<SourceItem>('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)},true)} export function createSource(payload:{name:string;source_url?:string;notes:string}){return apiRequest<SourceItem>('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)},true)}
+5 -1
View File
@@ -8,14 +8,18 @@ const router=createRouter({history:createWebHistory(),routes:[
{path:'/',component:App}, {path:'/',component:App},
{path:'/login',component:()=>import('./views/LoginView.vue'),meta:{public:true}}, {path:'/login',component:()=>import('./views/LoginView.vue'),meta:{public:true}},
{path:'/register',component:()=>import('./views/RegisterView.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:'/profile',component:()=>import('./views/ProfileView.vue')},
{path:'/settings',component:()=>import('./views/AccountSettingsView.vue')}, {path:'/settings',component:()=>import('./views/AccountSettingsView.vue')},
{path:'/indexes',component:()=>import('./views/IndexManagementView.vue'),meta:{role:'admin'}}, {path:'/indexes',component:()=>import('./views/IndexManagementView.vue'),meta:{role:'admin'}},
{path:'/reviews',component:()=>import('./views/KnowledgeReviewView.vue'),meta:{role:'admin'}}, {path:'/reviews',component:()=>import('./views/KnowledgeReviewView.vue'),meta:{role:'admin'}},
{path:'/knowledge',component:()=>import('./views/KnowledgeManagementView.vue'),meta:{role:'admin'}},
{path:'/documents',component:()=>import('./views/DocumentManagementView.vue'),meta:{role:'admin'}}, {path:'/documents',component:()=>import('./views/DocumentManagementView.vue'),meta:{role:'admin'}},
{path:'/sources',component:()=>import('./views/SourceManagementView.vue'),meta:{role:'admin'}}, {path:'/sources',component:()=>import('./views/SourceManagementView.vue'),meta:{role:'admin'}},
{path:'/retrieval-exports',component:()=>import('./views/RetrievalExportView.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:'/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 export default router
+6
View File
@@ -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)}
+6 -4
View File
@@ -1,20 +1,22 @@
import { apiRequest } from './session' import { apiRequest } from './session'
import type { Chunk } from './indexes' import type { Chunk } from './indexes'
export type CompletionOptions = { model: string; output_tokens?: number; token_parameter?: 'max_completion_tokens' | 'max_tokens'; instruction_role?: 'system' | 'developer'; temperature?: number | null } export type CompletionOptions = { protocol?: 'responses'; provider_profile?: 'compatible' | 'openai' | 'deepseek' | 'bailian'; model: string; output_tokens?: number; temperature?: number | null }
export type LegacyCompletionOptions = { model: string; output_tokens?: number; token_parameter?: 'max_completion_tokens' | 'max_tokens'; instruction_role?: 'system' | 'developer'; temperature?: number | null }
export type PrepareRagContext = { retrieval_run_id: string; completion: CompletionOptions; max_input_characters?: number; max_evidence?: number } export type PrepareRagContext = { retrieval_run_id: string; completion: CompletionOptions; max_input_characters?: number; max_evidence?: number }
export type RagEvidence = Chunk['provenance'] & { citation_id: string; chunk_id: string; chunk_ordinal: number; retrieval_rank: number; text: string; text_sha256: string; start_offset: number; end_offset: number } export type RagEvidence = Chunk['provenance'] & { citation_id: string; chunk_id: string; chunk_ordinal: number; retrieval_rank: number; text: string; text_sha256: string; start_offset: number; end_offset: number }
export type ChatCompletionsBody = { model: string; messages: { role: 'system' | 'developer' | 'user'; content: string }[]; stream: false; n: 1; store: false; max_completion_tokens?: number; max_tokens?: number; temperature?: number } export type ChatCompletionsBody = { model: string; messages: { role: 'system' | 'developer' | 'user'; content: string }[]; stream: false; n: 1; store: false; max_completion_tokens?: number; max_tokens?: number; temperature?: number }
export type ResponsesBody = { model: string; instructions: string; input: { role: 'user'; content: { type: 'input_text'; text: string }[] }[]; stream: false; max_output_tokens: number; store?: false; temperature?: number }
export type RagContext = { export type RagContext = {
id: string; kind: 'prepared_request'; model_called: false; status: 'prepared' | 'insufficient_evidence'; id: string; kind: 'prepared_request'; model_called: false; status: 'prepared' | 'insufficient_evidence';
reason: 'candidates_available' | 'no_candidates' | 'context_budget'; retrieval_run_id: string; reason: 'candidates_available' | 'no_candidates' | 'context_budget'; retrieval_run_id: string;
retrieval_mode: 'keyword_chunks' | 'vector' | 'hybrid'; query: string; text_index_version_id: string; retrieval_mode: 'keyword_chunks' | 'vector' | 'hybrid'; query: string; text_index_version_id: string;
text_generation: number; catalog_revision: number; vector_version_id: string | null; vector_generation: number | null; text_generation: number; catalog_revision: number; vector_version_id: string | null; vector_generation: number | null;
corpus_fingerprint: string; prompt_version: 'nalati-evidence-v1'; completion: Required<CompletionOptions>; corpus_fingerprint: string; prompt_version: 'nalati-evidence-v1';
max_input_characters: number; input_characters: number; max_evidence: number; evidence: RagEvidence[]; max_input_characters: number; input_characters: number; max_evidence: number; evidence: RagEvidence[];
excluded: { chunk_id: string; reason: 'overlap' | 'budget' | 'evidence_limit' }[]; excluded: { chunk_id: string; reason: 'overlap' | 'budget' | 'evidence_limit' }[];
request_body: ChatCompletionsBody | null; request_sha256: string | null; created_at: string; request_sha256: string | null; created_at: string;
} } & ({ completion: Required<CompletionOptions>; request_body: ResponsesBody | null } | { completion: Required<LegacyCompletionOptions>; request_body: ChatCompletionsBody | null })
export const prepareRagContext = (payload: PrepareRagContext) => apiRequest<RagContext>('/api/rag/contexts', { method: 'POST', body: JSON.stringify(payload) }, true) export const prepareRagContext = (payload: PrepareRagContext) => apiRequest<RagContext>('/api/rag/contexts', { method: 'POST', body: JSON.stringify(payload) }, true)
export const getRagContext = (id: string) => apiRequest<RagContext>(`/api/rag/contexts/${encodeURIComponent(id)}`) export const getRagContext = (id: string) => apiRequest<RagContext>(`/api/rag/contexts/${encodeURIComponent(id)}`)
export const getRagContexts = (page = 1, pageSize = 20) => apiRequest<{ items: RagContext[]; total: number; page: number; page_size: number }>(`/api/rag/contexts?page=${page}&page_size=${pageSize}`) export const getRagContexts = (page = 1, pageSize = 20) => apiRequest<{ items: RagContext[]; total: number; page: number; page_size: number }>(`/api/rag/contexts?page=${page}&page_size=${pageSize}`)
+16
View File
@@ -0,0 +1,16 @@
// datetime-local contains wall-clock time; it must not be filled with UTC clock digits.
export function localDateTimeInput(value: string): string {
const date = new Date(value)
const shifted = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
const [seconds, fraction] = shifted.toISOString().slice(0, -1).split('.')
const significantFraction = fraction.replace(/0+$/, '')
if (significantFraction) return `${seconds}.${significantFraction}`
return seconds.endsWith(':00') ? seconds.slice(0, -3) : seconds
}
export function collectedAtFromInput(value: string, original?: string): string {
// Preserve server precision (including microseconds) and ambiguous DST instants
// when the administrator only changes other document fields.
if (original && (value === localDateTimeInput(original) || new Date(value).getTime() === new Date(original).getTime())) return original
return new Date(value).toISOString()
}
+1 -1
View File
@@ -29,7 +29,7 @@ onMounted(() => load(0))
<main> <main>
<h1>管理员用户管理</h1> <h1>管理员用户管理</h1>
<p>用户数据由受服务端管理员权限保护的接口实时提供。</p> <p>用户数据由受服务端管理员权限保护的接口实时提供。</p>
<p><RouterLink to="/reviews">进入知识审核工作台</RouterLink> <RouterLink to="/sources">进入来源管理</RouterLink> <RouterLink to="/documents">进入文档管理</RouterLink> <RouterLink to="/indexes">进入索引管理</RouterLink> <RouterLink to="/retrieval-exports">导出检索历史</RouterLink></p> <p><RouterLink to="/knowledge">进入知识管理</RouterLink> <RouterLink to="/reviews">进入知识审核工作台</RouterLink> <RouterLink to="/sources">进入来源管理</RouterLink> <RouterLink to="/documents">进入文档管理</RouterLink> <RouterLink to="/indexes">进入索引管理</RouterLink> <RouterLink to="/retrieval-exports">导出检索历史</RouterLink></p>
<p v-if="error" role="alert">{{ error }}</p> <p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="loading">正在读取用户列表…</p> <p v-else-if="loading">正在读取用户列表…</p>
<template v-else> <template v-else>
@@ -1,20 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { createDocument, deleteDocument, getDocumentHistory, getDocuments, getSources, updateDocument, type AuditItem, type DocumentItem, type SourceItem } from '../api' import { createDocument, deleteDocument, getDocumentHistory, getDocuments, getSources, updateDocument, type AuditItem, type DocumentItem, type SourceItem } from '../api'
import { collectedAtFromInput, localDateTimeInput } from '../utils/datetime'
type DocumentForm={source_id:number;title:string;category:string;content:string;collected_at:string;notes:string} type DocumentForm={source_id:number;title:string;category:string;content:string;collected_at:string;notes:string}
const blankForm=():DocumentForm=>({source_id:0,title:'',category:'未分类',content:'',collected_at:new Date().toISOString().slice(0,16),notes:''}) const blankForm=():DocumentForm=>({source_id:0,title:'',category:'未分类',content:'',collected_at:'',notes:''})
const documents=ref<DocumentItem[]>([]), sources=ref<SourceItem[]>([]), query=ref(''), page=ref(1), total=ref(0), loading=ref(true), saving=ref(false), error=ref(''), notice=ref('') const documents=ref<DocumentItem[]>([]), sources=ref<SourceItem[]>([]), query=ref(''), page=ref(1), total=ref(0), loading=ref(true), saving=ref(false), error=ref(''), notice=ref('')
const form=ref<DocumentForm>(blankForm()), editing=ref<DocumentItem>(), historyFor=ref<DocumentItem>(), historyItems=ref<AuditItem[]>([]), historyLoading=ref(false) const form=ref<DocumentForm>(blankForm()), editing=ref<DocumentItem>(), historyFor=ref<DocumentItem>(), historyItems=ref<AuditItem[]>([]), historyLoading=ref(false)
function message(cause:unknown,fallback:string){return cause instanceof Error?cause.message:fallback} function message(cause:unknown,fallback:string){return cause instanceof Error?cause.message:fallback}
function localTime(value:string){return new Date(value).toLocaleString()} function localTime(value:string){return new Date(value).toLocaleString()}
async function refresh(next=page.value){loading.value=true;error.value='';try{const result=await getDocuments(query.value,next);documents.value=result.items;total.value=result.total;page.value=result.page;if(!sources.value.length)sources.value=(await getSources()).items}catch(cause){error.value=message(cause,'无法读取文档')}finally{loading.value=false}} async function refresh(next=page.value){loading.value=true;error.value='';try{const result=await getDocuments(query.value,next);documents.value=result.items;total.value=result.total;page.value=result.page;if(!sources.value.length)sources.value=(await getSources()).items}catch(cause){error.value=message(cause,'无法读取文档')}finally{loading.value=false}}
function beginEdit(doc:DocumentItem){editing.value=doc;form.value={source_id:doc.source_id,title:doc.title,category:doc.category,content:doc.content,collected_at:new Date(doc.collected_at).toISOString().slice(0,16),notes:doc.notes};notice.value=''} function beginEdit(doc:DocumentItem){editing.value=doc;form.value={source_id:doc.source_id,title:doc.title,category:doc.category,content:doc.content,collected_at:localDateTimeInput(doc.collected_at),notes:doc.notes};notice.value=''}
function cancelEdit(){editing.value=undefined;form.value=blankForm()} function cancelEdit(){editing.value=undefined;form.value=blankForm()}
function valid(){if(!form.value.source_id||!form.value.title.trim()||!form.value.content.trim()||!form.value.collected_at){error.value='请填写来源、标题、正文和采集时间。';return false}return true} function valid(){if(!form.value.source_id||!form.value.title.trim()||!form.value.content.trim()||!form.value.collected_at){error.value='请填写来源、标题、正文和采集时间。';return false}return true}
async function submit(){error.value='';notice.value='';if(!valid())return;saving.value=true;const isEditing=Boolean(editing.value);try{const common={title:form.value.title.trim(),category:form.value.category.trim(),content:form.value.content.trim(),collected_at:new Date(form.value.collected_at).toISOString(),notes:form.value.notes.trim()};if(editing.value){await updateDocument(editing.value.id,{expected_version:editing.value.version,...common});notice.value='文档已更新;关联知识会由后端重新置为待审核。'}else{await createDocument({source_id:form.value.source_id,...common});notice.value='文档已创建,可在后续知识条目中关联并审核。'}cancelEdit();await refresh(isEditing?page.value:1)}catch(cause){error.value=message(cause,isEditing?'更新文档失败':'创建文档失败')}finally{saving.value=false}} async function submit(){error.value='';notice.value='';if(!valid())return;saving.value=true;const isEditing=Boolean(editing.value);try{const common={title:form.value.title.trim(),category:form.value.category.trim(),content:form.value.content.trim(),collected_at:collectedAtFromInput(form.value.collected_at,editing.value?.collected_at),notes:form.value.notes.trim()};if(editing.value){await updateDocument(editing.value.id,{expected_version:editing.value.version,...common});notice.value='文档已更新;关联知识会由后端重新置为待审核。'}else{await createDocument({source_id:form.value.source_id,...common});notice.value='文档已创建,可在后续知识条目中关联并审核。'}cancelEdit();await refresh(isEditing?page.value:1)}catch(cause){error.value=message(cause,isEditing?'更新文档失败':'创建文档失败')}finally{saving.value=false}}
async function remove(doc:DocumentItem){if(!window.confirm(`确定删除“${doc.title}”吗?若仍被知识条目引用,后端会拒绝删除。`))return;error.value='';notice.value='';try{await deleteDocument(doc.id,doc.version);notice.value='文档已软删除,历史记录仍可追溯。';if(editing.value?.id===doc.id)cancelEdit();await refresh(documents.value.length===1&&page.value>1?page.value-1:page.value)}catch(cause){error.value=message(cause,'删除文档失败')}} async function remove(doc:DocumentItem){if(!window.confirm(`确定删除“${doc.title}”吗?若仍被知识条目引用,后端会拒绝删除。`))return;error.value='';notice.value='';try{await deleteDocument(doc.id,doc.version);notice.value='文档已软删除,历史记录仍可追溯。';if(editing.value?.id===doc.id)cancelEdit();await refresh(documents.value.length===1&&page.value>1?page.value-1:page.value)}catch(cause){error.value=message(cause,'删除文档失败')}}
async function showHistory(doc:DocumentItem){historyFor.value=doc;historyLoading.value=true;error.value='';try{historyItems.value=(await getDocumentHistory(doc.id)).items}catch(cause){error.value=message(cause,'无法读取文档历史');historyFor.value=undefined}finally{historyLoading.value=false}} async function showHistory(doc:DocumentItem){historyFor.value=doc;historyLoading.value=true;error.value='';try{historyItems.value=(await getDocumentHistory(doc.id)).items}catch(cause){error.value=message(cause,'无法读取文档历史');historyFor.value=undefined}finally{historyLoading.value=false}}
onMounted(()=>refresh(1)) onMounted(()=>refresh(1))
</script> </script>
<template><main><RouterLink to="/admin">返回管理员工作台</RouterLink><h1>文档管理</h1><p>文档必须关联已有来源;创建、编辑、删除和历史记录均调用管理员受保护的 FastAPI 接口。</p><p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p><form @submit.prevent="submit"><h2>{{editing?'编辑文档':'新增文档'}}</h2><label>来源<select v-model.number="form.source_id" required :disabled="Boolean(editing)"><option :value="0" disabled>请选择来源</option><option v-for="source in sources" :key="source.id" :value="source.id">{{source.name}}</option></select></label><p v-if="editing">来源归属不可修改;如需变更来源,请新建文档。</p><label>标题<input v-model.trim="form.title" required maxlength="250"/></label><label>分类<input v-model.trim="form.category" required maxlength="80"/></label><label>采集时间<input v-model="form.collected_at" type="datetime-local" required/></label><label>正文<textarea v-model.trim="form.content" required maxlength="100000" rows="6"/></label><label>备注<textarea v-model.trim="form.notes" maxlength="10000" rows="2"/></label><button :disabled="saving">{{saving?(editing?'更新中…':'保存中…'):(editing?'保存修改':'保存文档')}}</button><button v-if="editing" type="button" :disabled="saving" @click="cancelEdit">取消编辑</button></form><p v-if="loading">正在读取文档…</p><template v-else><label>搜索<input v-model="query" @keyup.enter="refresh(1)"/></label><button @click="refresh(1)">查询</button><table><thead><tr><th>标题</th><th>分类</th><th>来源 ID</th><th>采集时间</th><th>操作</th></tr></thead><tbody><tr v-for="doc in documents" :key="doc.id"><td><b>{{doc.title}}</b><p>{{doc.content.slice(0,120)}}</p></td><td>{{doc.category}}</td><td>{{doc.source_id}}</td><td>{{localTime(doc.collected_at)}}</td><td><button @click="beginEdit(doc)">编辑</button><button @click="showHistory(doc)">历史</button><button @click="remove(doc)">删除</button></td></tr><tr v-if="!documents.length"><td colspan="5">暂无文档。</td></tr></tbody></table><div><button :disabled="page===1" @click="refresh(page-1)">上一页</button><span>第 {{page}} 页,共 {{total}} 条</span><button :disabled="page*20>=total" @click="refresh(page+1)">下一页</button></div></template><section v-if="historyFor"><h2>“{{historyFor.title}}”的历史记录</h2><button @click="historyFor=undefined">关闭历史</button><p v-if="historyLoading">正在读取历史…</p><table v-else><thead><tr><th>操作</th><th>时间</th><th>操作者</th><th>版本快照</th></tr></thead><tbody><tr v-for="item in historyItems" :key="item.id"><td>{{item.action}}</td><td>{{localTime(item.created_at)}}</td><td>{{item.actor||'系统'}}</td><td><details><summary>查看快照</summary><pre>{{JSON.stringify(item.snapshot,null,2)}}</pre></details></td></tr><tr v-if="!historyItems.length"><td colspan="4">暂无历史记录。</td></tr></tbody></table></section></main></template> <template><main><RouterLink to="/admin">返回管理员工作台</RouterLink><h1>文档管理</h1><p>文档必须关联已有来源;创建、编辑、删除和历史记录均调用管理员受保护的 FastAPI 接口。</p><p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p><form @submit.prevent="submit"><h2>{{editing?'编辑文档':'新增文档'}}</h2><label>来源<select v-model.number="form.source_id" required :disabled="Boolean(editing)"><option :value="0" disabled>请选择来源</option><option v-for="source in sources" :key="source.id" :value="source.id">{{source.name}}</option></select></label><p v-if="editing">来源归属不可修改;如需变更来源,请新建文档。</p><label>标题<input v-model.trim="form.title" required maxlength="250"/></label><label>分类<input v-model.trim="form.category" required maxlength="80"/></label><label>采集时间<input v-model="form.collected_at" type="datetime-local" step="any" required/></label><label>正文<textarea v-model.trim="form.content" required maxlength="100000" rows="6"/></label><label>备注<textarea v-model.trim="form.notes" maxlength="10000" rows="2"/></label><button :disabled="saving">{{saving?(editing?'更新中…':'保存中…'):(editing?'保存修改':'保存文档')}}</button><button v-if="editing" type="button" :disabled="saving" @click="cancelEdit">取消编辑</button></form><p v-if="loading">正在读取文档…</p><template v-else><label>搜索<input v-model="query" @keyup.enter="refresh(1)"/></label><button @click="refresh(1)">查询</button><table><thead><tr><th>标题</th><th>分类</th><th>来源 ID</th><th>采集时间</th><th>操作</th></tr></thead><tbody><tr v-for="doc in documents" :key="doc.id"><td><b>{{doc.title}}</b><p>{{doc.content.slice(0,120)}}</p></td><td>{{doc.category}}</td><td>{{doc.source_id}}</td><td>{{localTime(doc.collected_at)}}</td><td><button @click="beginEdit(doc)">编辑</button><button @click="showHistory(doc)">历史</button><button @click="remove(doc)">删除</button></td></tr><tr v-if="!documents.length"><td colspan="5">暂无文档。</td></tr></tbody></table><div><button :disabled="page===1" @click="refresh(page-1)">上一页</button><span>第 {{page}} 页,共 {{total}} 条</span><button :disabled="page*20>=total" @click="refresh(page+1)">下一页</button></div></template><section v-if="historyFor"><h2>“{{historyFor.title}}”的历史记录</h2><button @click="historyFor=undefined">关闭历史</button><p v-if="historyLoading">正在读取历史…</p><table v-else><thead><tr><th>操作</th><th>时间</th><th>操作者</th><th>版本快照</th></tr></thead><tbody><tr v-for="item in historyItems" :key="item.id"><td>{{item.action}}</td><td>{{localTime(item.created_at)}}</td><td>{{item.actor||'系统'}}</td><td><details><summary>查看快照</summary><pre>{{JSON.stringify(item.snapshot,null,2)}}</pre></details></td></tr><tr v-if="!historyItems.length"><td colspan="4">暂无历史记录。</td></tr></tbody></table></section></main></template>
+1
View File
@@ -0,0 +1 @@
<template><main><h1>403 无权限</h1><p>当前账户没有访问此页面的权限。管理员能力仍必须由后端授权。</p><RouterLink to="/">返回首页</RouterLink></main></template>
+10
View File
@@ -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,19 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { deleteKnowledge, getKnowledgeHistory, getKnowledgePage, updateKnowledge, type AuditItem, type KnowledgeItem, type VerificationStatus } from '../api'
import { collectedAtFromInput, localDateTimeInput } from '../utils/datetime'
type Form={title:string;category:string;content:string;source_url:string;collected_at:string}
const items=ref<KnowledgeItem[]>([]), selected=ref<KnowledgeItem>(), historyFor=ref<KnowledgeItem>(), historyItems=ref<AuditItem[]>([])
const query=ref(''), category=ref(''), status=ref<VerificationStatus|''>(''), page=ref(1), total=ref(0), loading=ref(true), saving=ref(false), historyLoading=ref(false), error=ref(''), notice=ref('')
const form=ref<Form>({title:'',category:'',content:'',source_url:'',collected_at:''})
function failure(cause:unknown,fallback:string){return cause instanceof Error?cause.message:fallback}
function validUrl(value:string){if(!value)return true;try{const url=new URL(value);return url.protocol==='http:'||url.protocol==='https:'}catch{return false}}
async function refresh(next=page.value){loading.value=true;error.value='';try{const result=await getKnowledgePage({q:query.value,category:category.value,verification_status:status.value||undefined,page:next});items.value=result.items;page.value=result.page;total.value=result.total}catch(cause){error.value=failure(cause,'无法读取知识条目')}finally{loading.value=false}}
function edit(item:KnowledgeItem){selected.value=item;form.value={title:item.title,category:item.category,content:item.content,source_url:item.source_url??'',collected_at:item.collected_at?localDateTimeInput(item.collected_at):''};error.value='';notice.value=''}
function cancel(){selected.value=undefined}
async function save(){if(!selected.value)return;error.value='';notice.value='';if(!form.value.title.trim()||!form.value.category.trim()||!form.value.content.trim()){error.value='标题、分类和正文不能为空。';return}if(!validUrl(form.value.source_url.trim())){error.value='来源网址必须是有效的 http(s) 地址。';return}saving.value=true;try{const payload={expected_version:selected.value.version,title:form.value.title.trim(),category:form.value.category.trim(),content:form.value.content.trim(),source_url:form.value.source_url.trim()||null,...(form.value.collected_at?{collected_at:collectedAtFromInput(form.value.collected_at,selected.value.collected_at??undefined)}: {})};await updateKnowledge(selected.value.id,payload);notice.value='知识条目已更新,后端已将其重新置为待审核。';cancel();await refresh()}catch(cause){error.value=failure(cause,'更新知识条目失败')}finally{saving.value=false}}
async function remove(item:KnowledgeItem){if(!window.confirm(`确定删除“${item.title}”吗?该操作为软删除,历史记录会保留。`))return;error.value='';notice.value='';try{await deleteKnowledge(item.id,item.version);notice.value='知识条目已软删除。';if(selected.value?.id===item.id)cancel();await refresh(items.value.length===1&&page.value>1?page.value-1:page.value)}catch(cause){error.value=failure(cause,'删除知识条目失败')}}
async function showHistory(item:KnowledgeItem){historyFor.value=item;historyLoading.value=true;error.value='';try{historyItems.value=(await getKnowledgeHistory(item.id)).items}catch(cause){error.value=failure(cause,'无法读取知识历史');historyFor.value=undefined}finally{historyLoading.value=false}}
onMounted(()=>refresh(1))
</script>
<template><main><RouterLink to="/admin">返回管理员工作台</RouterLink><h1>知识管理</h1><p>列表、编辑、删除和历史均直接调用管理员受保护接口;编辑后条目会由后端重新置为待审核。</p><p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p><form @submit.prevent="refresh(1)"><label>关键词<input v-model="query"/></label><label>分类<input v-model="category"/></label><label>状态<select v-model="status"><option value="">全部</option><option value="pending">待审核</option><option value="verified">已核验</option><option value="rejected">已驳回</option></select></label><button>筛选</button></form><p v-if="loading">正在读取知识条目…</p><template v-else><table><thead><tr><th>标题</th><th>分类</th><th>状态</th><th>来源</th><th>操作</th></tr></thead><tbody><tr v-for="item in items" :key="item.id"><td><b>{{item.title}}</b><p>{{item.content.slice(0,120)}}</p></td><td>{{item.category}}</td><td>{{item.verification_status}}</td><td><a v-if="item.source_url" :href="item.source_url" target="_blank" rel="noreferrer">打开来源</a><span v-else>未填写</span></td><td><button @click="edit(item)">编辑</button><button @click="showHistory(item)">历史</button><button @click="remove(item)">删除</button></td></tr><tr v-if="!items.length"><td colspan="5">暂无符合条件的知识条目。</td></tr></tbody></table><div><button :disabled="page===1" @click="refresh(page-1)">上一页</button><span>第 {{page}} 页,共 {{total}} 条</span><button :disabled="page*20>=total" @click="refresh(page+1)">下一页</button></div></template><form v-if="selected" @submit.prevent="save"><h2>编辑:{{selected.title}}</h2><label>标题<input v-model.trim="form.title" required maxlength="250"/></label><label>分类<input v-model.trim="form.category" required maxlength="80"/></label><label>正文<textarea v-model.trim="form.content" required maxlength="100000" rows="6"/></label><label>来源网址<input v-model.trim="form.source_url" type="url" maxlength="1000" placeholder="https://..."/></label><label>采集时间<input v-model="form.collected_at" type="datetime-local" step="any"/></label><button type="button" :disabled="saving" @click="cancel">取消</button><button :disabled="saving">{{saving?'保存中…':'保存修改'}}</button></form><section v-if="historyFor"><h2>“{{historyFor.title}}”的历史</h2><button @click="historyFor=undefined">关闭历史</button><p v-if="historyLoading">正在读取历史…</p><table v-else><thead><tr><th>操作</th><th>时间</th><th>操作者</th><th>版本快照</th></tr></thead><tbody><tr v-for="item in historyItems" :key="item.id"><td>{{item.action}}</td><td>{{new Date(item.created_at).toLocaleString()}}</td><td>{{item.actor||'系统'}}</td><td><details><summary>查看快照</summary><pre>{{JSON.stringify(item.snapshot,null,2)}}</pre></details></td></tr><tr v-if="!historyItems.length"><td colspan="4">暂无历史记录。</td></tr></tbody></table></section></main></template>
+10 -1
View File
@@ -1 +1,10 @@
<script setup lang="ts">import { ref } from 'vue';import { useRoute,useRouter } from 'vue-router';import { login } from '../services/auth';const username=ref(''),password=ref(''),error=ref(''),router=useRouter(),route=useRoute();async function submit(){try{await login({username:username.value,password:password.value});await router.replace(typeof route.query.redirect==='string'?route.query.redirect:'/')}catch(e){error.value=e instanceof Error?e.message:'登录失败'}}</script><template><main><h1>登录</h1><form @submit.prevent="submit"><input v-model="username" autocomplete="username" required placeholder="用户名"><input v-model="password" type="password" autocomplete="current-password" required placeholder="密码"><p v-if="error">{{error}}</p><button>登录</button></form><RouterLink to="/register">注册账户</RouterLink></main></template> <script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { login } from '../services/auth'
const username=ref(''),password=ref(''),error=ref(''),submitting=ref(false),router=useRouter(),route=useRoute()
const redirect=computed(()=>typeof route.query.redirect==='string'?route.query.redirect:'/')
async function submit(){if(submitting.value)return;error.value='';submitting.value=true;try{await login({username:username.value.trim(),password:password.value});await router.replace(redirect.value)}catch(cause){error.value=cause instanceof Error?cause.message:'登录失败,请稍后重试。'}finally{submitting.value=false}}
</script>
<template><main class="auth-page"><section class="auth-intro"><p>边疆红驿 · NALATI KNOWLEDGE HUB</p><h1>每一次提问,<br>都有据可查。</h1><span>聚焦那拉提红色文化、旅游资源与民宿服务的可信知识问答。</span></section><section class="auth-panel"><RouterLink to="/">边疆红驿</RouterLink><form class="auth-card" @submit.prevent="submit"><p>WELCOME BACK</p><h2>登录账户</h2><label>用户名<input v-model.trim="username" autocomplete="username" required maxlength="100" placeholder="请输入用户名"/></label><label>密码<input v-model="password" type="password" autocomplete="current-password" required placeholder="请输入密码"/></label><p v-if="error" class="auth-error" role="alert">{{error}}</p><button :disabled="submitting">{{submitting?'登录中…':'登录'}}</button><small>还没有账户? <RouterLink to="/register">注册账户</RouterLink></small></form></section></main></template>
<style scoped>.auth-page{min-height:100vh;display:grid;grid-template-columns:1.1fr .9fr;background:#f5f3eb}.auth-intro{padding:12vw;background:#214d3b;color:#fff;display:flex;flex-direction:column;justify-content:center}.auth-intro p,.auth-card>p{letter-spacing:.14em;font-size:11px;font-weight:700;color:#b8d1be}.auth-intro h1{font-size:clamp(42px,5vw,68px);line-height:1.08;letter-spacing:-.06em;margin:20px 0}.auth-intro span{max-width:410px;line-height:1.9;color:#d7e7da}.auth-panel{padding:34px;display:flex;flex-direction:column;align-items:center;color:#214d3b;font-weight:700}.auth-panel>a{align-self:stretch}.auth-card{width:min(100%,410px);margin:auto 0;padding:34px;border:1px solid #e3e1d8;border-radius:16px;background:#fffefa;box-shadow:0 18px 45px #27332b14;display:grid;gap:16px}.auth-card h2{margin:0;font-size:30px;letter-spacing:-.04em;color:#27332b}.auth-card label{display:grid;gap:7px;font-size:12px;color:#526052}.auth-card input{padding:12px;border:1px solid #dfe2d8;border-radius:9px;font:inherit;outline-color:#6f967c}.auth-card button{border:0;border-radius:9px;padding:13px;background:#214d3b;color:#fff;font-weight:700}.auth-card small{color:#707a70;font-weight:400}.auth-error{margin:0;padding:10px;border-radius:8px;background:#f8e9e4;color:#a5423a;font-size:12px}@media(max-width:780px){.auth-page{grid-template-columns:1fr}.auth-intro{padding:48px 28px}.auth-panel{padding:22px}.auth-card{margin:44px 0}}</style>
+1
View File
@@ -0,0 +1 @@
<template><main><h1>404 页面不存在</h1><p>请检查地址,或返回智能问答首页。</p><RouterLink to="/">返回首页</RouterLink></main></template>
+9 -1
View File
@@ -1 +1,9 @@
<script setup lang="ts">import { ref } from 'vue';import { useRouter } from 'vue-router';import { register } from '../services/auth';const username=ref(''),password=ref(''),error=ref(''),router=useRouter();async function submit(){try{await register({username:username.value,password:password.value});await router.push('/login')}catch(e){error.value=e instanceof Error?e.message:'注册失败'}}</script><template><main><h1>注册</h1><form @submit.prevent="submit"><input v-model="username" required minlength="3" placeholder="用户名"><input v-model="password" required minlength="15" type="password" placeholder="至少 15 位密码"><p v-if="error">{{error}}</p><button>注册</button></form></main></template> <script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { register } from '../services/auth'
const username=ref(''),password=ref(''),confirmPassword=ref(''),error=ref(''),notice=ref(''),submitting=ref(false),router=useRouter()
async function submit(){if(submitting.value)return;error.value='';notice.value='';if(password.value!==confirmPassword.value){error.value='两次输入的密码不一致。';return}submitting.value=true;try{await register({username:username.value.trim(),password:password.value});notice.value='注册成功,正在跳转登录页…';window.setTimeout(()=>router.push('/login'),500)}catch(cause){error.value=cause instanceof Error?cause.message:'注册失败,请稍后重试。'}finally{submitting.value=false}}
</script>
<template><main class="auth-page"><section class="auth-panel"><RouterLink to="/">边疆红驿</RouterLink><form class="auth-card" @submit.prevent="submit"><p>CREATE ACCOUNT</p><h1>注册账户</h1><span>新注册账户默认为普通用户;管理员权限仅能由后端授权。</span><label>用户名<input v-model.trim="username" autocomplete="username" required minlength="3" maxlength="100" placeholder="至少 3 个字符"/></label><label>密码<input v-model="password" autocomplete="new-password" required minlength="15" type="password" placeholder="至少 15 位密码"/></label><label>确认密码<input v-model="confirmPassword" autocomplete="new-password" required type="password" placeholder="再次输入密码"/></label><p v-if="error" class="auth-error" role="alert">{{error}}</p><p v-if="notice" class="auth-notice" role="status">{{notice}}</p><button :disabled="submitting">{{submitting?'注册中…':'创建账户'}}</button><small>已有账户? <RouterLink to="/login">前往登录</RouterLink></small></form></section><section class="auth-intro"><p>边疆红驿 · NALATI KNOWLEDGE HUB</p><h2>从可靠资料出发,<br>再开始提问。</h2><span>未审核知识不会进入正式检索;没有可靠依据时,系统会明确说明知识不足。</span></section></main></template>
<style scoped>.auth-page{min-height:100vh;display:grid;grid-template-columns:1.1fr .9fr;background:#f5f3eb}.auth-intro{padding:12vw;background:#214d3b;color:#fff;display:flex;flex-direction:column;justify-content:center;order:0}.auth-intro p,.auth-card>p{letter-spacing:.14em;font-size:11px;font-weight:700;color:#b8d1be}.auth-intro h2{font-size:clamp(42px,5vw,68px);line-height:1.08;letter-spacing:-.06em;margin:20px 0}.auth-intro span{max-width:410px;line-height:1.9;color:#d7e7da}.auth-panel{padding:34px;display:flex;flex-direction:column;align-items:center;color:#214d3b;font-weight:700;order:1}.auth-panel>a{align-self:stretch}.auth-card{width:min(100%,410px);margin:auto 0;padding:34px;border:1px solid #e3e1d8;border-radius:16px;background:#fffefa;box-shadow:0 18px 45px #27332b14;display:grid;gap:16px}.auth-card h1{margin:0;font-size:30px;letter-spacing:-.04em;color:#27332b}.auth-card>span{color:#707a70;font-size:12px;line-height:1.7;font-weight:400}.auth-card label{display:grid;gap:7px;font-size:12px;color:#526052}.auth-card input{padding:12px;border:1px solid #dfe2d8;border-radius:9px;font:inherit;outline-color:#6f967c}.auth-card button{border:0;border-radius:9px;padding:13px;background:#214d3b;color:#fff;font-weight:700}.auth-card small{color:#707a70;font-weight:400}.auth-error,.auth-notice{margin:0;padding:10px;border-radius:8px;font-size:12px;letter-spacing:normal!important}.auth-error{background:#f8e9e4;color:#a5423a}.auth-notice{background:#e8f1e8;color:#356443}@media(max-width:780px){.auth-page{grid-template-columns:1fr}.auth-panel{padding:22px;order:2}.auth-intro{padding:48px 28px;order:1}.auth-card{margin:44px 0}}</style>
+59
View File
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import ts from 'typescript'
const source = await readFile(new URL('../src/utils/datetime.ts', import.meta.url), 'utf8')
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } })
const { localDateTimeInput, collectedAtFromInput } = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString('base64')}`)
function timezone(t, zone) {
const original = process.env.TZ
process.env.TZ = zone
t.after(() => { if (original === undefined) delete process.env.TZ; else process.env.TZ = original })
}
test('Shanghai display uses local clock and unchanged input preserves microseconds', t => {
timezone(t, 'Asia/Shanghai')
const original = '2026-10-16T04:34:56.789123Z'
const value = localDateTimeInput(original)
assert.equal(value, '2026-10-16T12:34:56.789')
assert.equal(collectedAtFromInput(value, original), original)
const offsetOriginal = '2026-10-16T12:34:56.789123+08:00'
assert.equal(collectedAtFromInput(value, offsetOriginal), offsetOriginal)
})
test('zero milliseconds are normalized like datetime-local without losing server fractions', t => {
timezone(t, 'Asia/Shanghai')
const original = '2026-10-16T04:34:00.000123Z'
assert.equal(localDateTimeInput(original), '2026-10-16T12:34')
assert.equal(collectedAtFromInput('2026-10-16T12:34', original), original)
assert.equal(localDateTimeInput('2026-10-16T04:34:56.010Z'), '2026-10-16T12:34:56.01')
})
test('explicit local time changes convert to the correct UTC instant', t => {
timezone(t, 'Asia/Shanghai')
assert.equal(collectedAtFromInput('2026-10-16T12:35:56.789', '2026-10-16T04:34:56.789123Z'), '2026-10-16T04:35:56.789Z')
assert.equal(collectedAtFromInput('2026-10-16T12:35'), '2026-10-16T04:35:00.000Z')
})
test('negative UTC offsets preserve date boundaries', t => {
timezone(t, 'America/Los_Angeles')
const original = '2026-01-01T04:34:56.789123Z'
assert.equal(localDateTimeInput(original), '2025-12-31T20:34:56.789')
assert.equal(collectedAtFromInput(localDateTimeInput(original), original), original)
})
test('unchanged repeated DST clock time keeps the original instant and precision', t => {
timezone(t, 'America/New_York')
const original = '2026-11-01T06:30:00.000123Z'
assert.equal(localDateTimeInput(original), '2026-11-01T01:30')
assert.equal(collectedAtFromInput('2026-11-01T01:30', original), original)
})
test('UTC and invalid timestamps do not invent a collection time', t => {
timezone(t, 'UTC')
assert.equal(localDateTimeInput('2026-10-16T04:34:56.789Z'), '2026-10-16T04:34:56.789')
assert.throws(() => localDateTimeInput('invalid'))
assert.throws(() => collectedAtFromInput(''))
})