fix(agent): serialize retrieval trace responses
This commit is contained in:
@@ -46,13 +46,25 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||||
const body = (await response.json()) as ApiResponse<T> & { detail?: string };
|
const body = await readApiResponse<T>(response);
|
||||||
if (!response.ok || body.code !== 0) {
|
if (!response.ok || body.code !== 0) {
|
||||||
throw new Error(body.detail || body.message || `${response.status} ${response.statusText}`);
|
throw new Error(body.detail || body.message || `${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
return body.data;
|
return body.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readApiResponse<T>(response: Response): Promise<ApiResponse<T> & { detail?: string }> {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new Error(response.ok ? "接口返回为空" : `服务暂时不可用(${response.status})`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as ApiResponse<T> & { detail?: string };
|
||||||
|
} catch {
|
||||||
|
throw new Error(response.ok ? "接口返回格式不正确" : `服务暂时不可用(${response.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function download(path: string, filename: string) {
|
async function download(path: string, filename: string) {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -73,7 +85,7 @@ async function upload<T>(path: string, formData: FormData): Promise<T> {
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
const response = await fetch(`${API_BASE}${path}`, { method: "POST", body: formData, headers });
|
const response = await fetch(`${API_BASE}${path}`, { method: "POST", body: formData, headers });
|
||||||
const body = (await response.json()) as ApiResponse<T> & { detail?: string };
|
const body = await readApiResponse<T>(response);
|
||||||
if (!response.ok || body.code !== 0) {
|
if (!response.ok || body.code !== 0) {
|
||||||
throw new Error(body.message || body.detail || `${response.status} ${response.statusText}`);
|
throw new Error(body.message || body.detail || `${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
return JSONResponse(status_code=422, content=api_error(10000, "请求参数不正确", exc.errors()))
|
return JSONResponse(status_code=422, content=api_error(10000, "请求参数不正确", exc.errors()))
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=500, content=api_error(50000, "系统内部错误,请稍后重试"))
|
||||||
|
|
||||||
|
|
||||||
def _code_from_status(status_code: int) -> int:
|
def _code_from_status(status_code: int) -> int:
|
||||||
if status_code in {401, 403}:
|
if status_code in {401, 403}:
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ class KnowledgeAgentService:
|
|||||||
log.selected_knowledge_ids = ",".join(str(item) for item in selected_ids)
|
log.selected_knowledge_ids = ",".join(str(item) for item in selected_ids)
|
||||||
log.query_terms = json.dumps(terms, ensure_ascii=False)
|
log.query_terms = json.dumps(terms, ensure_ascii=False)
|
||||||
log.final_section_ids = ",".join(str(item.section.id) for item in selected)
|
log.final_section_ids = ",".join(str(item.section.id) for item in selected)
|
||||||
log.tool_trace = json.dumps(trace, ensure_ascii=False)
|
log.tool_trace = _dump_trace(trace)
|
||||||
log.tool_cost_ms = int((perf_counter() - started) * 1000)
|
log.tool_cost_ms = int((perf_counter() - started) * 1000)
|
||||||
log.status = "prepared"
|
log.status = "prepared"
|
||||||
db.add(log)
|
db.add(log)
|
||||||
@@ -152,7 +152,7 @@ class KnowledgeAgentService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.status = "failed"
|
log.status = "failed"
|
||||||
log.error_message = str(exc)
|
log.error_message = str(exc)
|
||||||
log.tool_trace = json.dumps(trace, ensure_ascii=False)
|
log.tool_trace = _dump_trace(trace)
|
||||||
log.tool_cost_ms = int((perf_counter() - started) * 1000)
|
log.tool_cost_ms = int((perf_counter() - started) * 1000)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
raise
|
raise
|
||||||
@@ -431,6 +431,18 @@ class KnowledgeAgentService:
|
|||||||
return content if len(content) <= 3200 else content[:3200].rstrip() + "\n[章节内容已按保护规则截断]"
|
return content if len(content) <= 3200 else content[:3200].rstrip() + "\n[章节内容已按保护规则截断]"
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_trace(trace: list[dict]) -> str:
|
||||||
|
"""Persist tool traces without failing on database datetime values."""
|
||||||
|
return json.dumps(trace, ensure_ascii=False, default=_json_trace_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_trace_value(value):
|
||||||
|
isoformat = getattr(value, "isoformat", None)
|
||||||
|
if callable(isoformat):
|
||||||
|
return isoformat()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def _extract_json(value: str) -> str:
|
def _extract_json(value: str) -> str:
|
||||||
match = re.search(r"\{[\s\S]*\}", value)
|
match = re.search(r"\{[\s\S]*\}", value)
|
||||||
if not match:
|
if not match:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -11,6 +13,7 @@ from app.models.knowledge import (
|
|||||||
Knowledge,
|
Knowledge,
|
||||||
KnowledgeChunk,
|
KnowledgeChunk,
|
||||||
KnowledgeManifest,
|
KnowledgeManifest,
|
||||||
|
KnowledgeRetrievalLog,
|
||||||
KnowledgeSection,
|
KnowledgeSection,
|
||||||
KnowledgeSourceSnapshot,
|
KnowledgeSourceSnapshot,
|
||||||
KnowledgeVersion,
|
KnowledgeVersion,
|
||||||
@@ -140,6 +143,22 @@ def test_course_question_searches_chunk_and_reads_parent_section():
|
|||||||
assert any(item["tool"] == "read_knowledge_sections" for item in result.tool_trace)
|
assert any(item["tool"] == "read_knowledge_sections" for item in result.tool_trace)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_trace_serializes_published_datetime():
|
||||||
|
with _database() as db:
|
||||||
|
knowledge = _add_published_knowledge(db, knowledge_id=1, name="亲子课程")
|
||||||
|
version = db.get(KnowledgeVersion, knowledge.current_version_id)
|
||||||
|
assert version is not None
|
||||||
|
version.published_at = datetime(2026, 7, 13, 10, 30, 0)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = asyncio.run(KnowledgeAgentService.build_result(db, question="课程里如何进行亲子沟通?"))
|
||||||
|
log = db.get(KnowledgeRetrievalLog, result.retrieval_log_id)
|
||||||
|
trace = json.loads(log.tool_trace)
|
||||||
|
|
||||||
|
catalog_call = next(item for item in trace if item["tool"] == "get_knowledge_catalog")
|
||||||
|
assert catalog_call["items"][0]["publishedAt"] == "2026-07-13T10:30:00"
|
||||||
|
|
||||||
|
|
||||||
def test_contextual_follow_up_is_rewritten_before_agent_decision():
|
def test_contextual_follow_up_is_rewritten_before_agent_decision():
|
||||||
with _database() as db:
|
with _database() as db:
|
||||||
history = [
|
history = [
|
||||||
|
|||||||
Reference in New Issue
Block a user