feat: 切换Agent自主知识工具检索链路

This commit is contained in:
2026-07-11 19:14:17 +08:00
parent d2d43de6d3
commit 6cadca8c8e
14 changed files with 863 additions and 62 deletions

View File

@@ -0,0 +1,203 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.knowledge import (
HumanAttentionHistory,
HumanAttentionRecord,
KnowledgeRetrievalCandidate,
KnowledgeRetrievalLog,
)
from app.schemas.knowledge import AttentionUpdateRequest
from app.services.admin_service import OperationLogService
router = APIRouter()
@router.get("/retrieval-log/list")
def retrieval_logs(
sessionId: int | None = Query(default=None),
knowledgeId: int | None = Query(default=None),
knowledgeCalled: bool | None = Query(default=None),
statusValue: str = Query(default="", alias="status"),
attentionCreated: bool | None = Query(default=None),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc()).limit(300)
if sessionId is not None:
query = query.where(KnowledgeRetrievalLog.session_id == sessionId)
if knowledgeId is not None:
query = query.where(KnowledgeRetrievalLog.selected_knowledge_ids.like(f"%{knowledgeId}%"))
if knowledgeCalled is not None:
query = query.where(KnowledgeRetrievalLog.knowledge_called == (1 if knowledgeCalled else 0))
if statusValue:
query = query.where(KnowledgeRetrievalLog.status == statusValue)
if attentionCreated is not None:
query = query.where(KnowledgeRetrievalLog.attention_created == (1 if attentionCreated else 0))
return api_success([_retrieval_dict(item, detail=False) for item in db.scalars(query).all()])
@router.get("/retrieval-log/{log_id}")
def retrieval_detail(
log_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
log = db.get(KnowledgeRetrievalLog, log_id)
if log is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="检索日志不存在")
candidates = db.scalars(
select(KnowledgeRetrievalCandidate)
.where(KnowledgeRetrievalCandidate.retrieval_log_id == log.id)
.order_by(KnowledgeRetrievalCandidate.selected.desc(), KnowledgeRetrievalCandidate.id)
).all()
data = _retrieval_dict(log, detail=True)
data["candidates"] = [
{
"id": item.id,
"knowledgeId": item.knowledge_id,
"versionId": item.version_id,
"chunkId": item.chunk_id,
"sectionId": item.section_id,
"lexicalScore": item.lexical_score,
"rerankScore": item.rerank_score,
"selected": item.selected == 1,
"discardReason": item.discard_reason,
}
for item in candidates
]
return api_success(data)
@router.get("/attention/list")
def attention_list(
priority: str = Query(default=""),
statusValue: str = Query(default="", alias="status"),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(HumanAttentionRecord).order_by(HumanAttentionRecord.id.desc()).limit(300)
if priority:
query = query.where(HumanAttentionRecord.priority == priority)
if statusValue:
query = query.where(HumanAttentionRecord.status == statusValue)
return api_success([_attention_dict(item) for item in db.scalars(query).all()])
@router.put("/attention/{attention_id}")
def update_attention(
attention_id: int,
payload: AttentionUpdateRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
item = db.get(HumanAttentionRecord, attention_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人工关注记录不存在")
previous = item.status
item.status = payload.status
item.handler_id = current_admin.id
item.handler_note = payload.note
db.add_all(
[
item,
HumanAttentionHistory(
attention_id=item.id,
from_status=previous,
to_status=payload.status,
note=payload.note,
operated_by=current_admin.id,
),
]
)
OperationLogService.write(db, admin_id=current_admin.id, module="attention", action="update", target_id=item.id)
db.commit()
return api_success(_attention_dict(item))
@router.delete("/attention/{attention_id}")
def delete_attention(
attention_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
item = db.get(HumanAttentionRecord, attention_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人工关注记录不存在")
histories = db.scalars(
select(HumanAttentionHistory).where(HumanAttentionHistory.attention_id == item.id)
).all()
for history in histories:
db.delete(history)
db.delete(item)
OperationLogService.write(db, admin_id=current_admin.id, module="attention", action="delete", target_id=attention_id)
db.commit()
return api_success()
def _retrieval_dict(item: KnowledgeRetrievalLog, *, detail: bool) -> dict:
data = {
"id": item.id,
"sessionId": item.session_id,
"messageId": item.message_id,
"userId": item.user_id,
"question": item.question,
"knowledgeCalled": item.knowledge_called == 1,
"selectedKnowledgeIds": item.selected_knowledge_ids,
"queryTerms": _json(item.query_terms),
"finalSectionIds": item.final_section_ids,
"status": item.status,
"toolCostMs": item.tool_cost_ms,
"totalCostMs": item.total_cost_ms,
"errorMessage": item.error_message,
"attentionCreated": item.attention_created == 1,
"createdAt": item.created_at,
}
if detail:
data.update(
{
"catalogSnapshot": _json(item.catalog_snapshot),
"catalogHash": item.catalog_hash,
"safetyRuleVersion": item.safety_rule_version,
"toolTrace": _json(item.tool_trace),
"discardedConflicts": _json(item.discarded_conflicts),
"finalAnswer": item.final_answer,
}
)
return data
def _attention_dict(item: HumanAttentionRecord) -> dict:
return {
"id": item.id,
"sessionId": item.session_id,
"messageId": item.message_id,
"userId": item.user_id,
"triggerMessage": item.trigger_message,
"problemSummary": item.problem_summary,
"triggerReason": item.trigger_reason,
"priority": item.priority,
"status": item.status,
"handlerId": item.handler_id,
"handlerNote": item.handler_note,
"createdAt": item.created_at,
"updatedAt": item.updated_at,
}
def _json(raw: str | None):
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return raw

View File

@@ -291,6 +291,31 @@ def migration_summary(
return api_success({"total": len(rows), "counts": counts, "items": [_knowledge_detail(item) for item in rows]})
@router.post("/knowledge-migration/run")
async def run_migration(
knowledgeId: int | None = Query(default=None),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
query = select(Knowledge).where(Knowledge.lifecycle_status == "active")
if knowledgeId is not None:
query = query.where(Knowledge.id == knowledgeId)
rows = db.scalars(query.order_by(Knowledge.id)).all()
results = []
for knowledge in rows:
try:
job = await KnowledgePipelineService.synchronize(
db, knowledge, admin_id=current_admin.id, mode="review"
)
results.append({"knowledgeId": knowledge.id, "ok": True, "job": _job_dict(job)})
except Exception as exc:
results.append({"knowledgeId": knowledge.id, "ok": False, "error": str(exc)})
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge_migration", action="run", target_id=knowledgeId)
db.commit()
return api_success({"total": len(results), "success": sum(1 for item in results if item["ok"]), "results": results})
def _require_super_admin(admin: Admin) -> None:
if admin.role is not None and admin.role.code != "super_admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可以执行此操作")

View File

@@ -19,6 +19,7 @@ from app.schemas.admin import (
)
from app.services.admin_service import OperationLogService
from app.services.feishu_service import FeishuKnowledgeService
from app.services.knowledge_agent_service import KnowledgeAgentService
from app.services.knowledge_service import KnowledgeScope
from app.services.model_service import ModelClientService
from app.services.rag_service import RagResult
@@ -60,7 +61,7 @@ def reset_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(g
@router.post("/agent/debug")
def debug_agent(
async def debug_agent(
payload: AgentDebugRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
@@ -69,10 +70,21 @@ def debug_agent(
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
scopes = _agent_knowledge_scopes(db, payload.knowledgeIds)
chunks = FeishuKnowledgeService.retrieve(payload.question, scopes, db)
prompt = _build_agent_debug_prompt(payload.promptContent, payload.question, chunks)
rag_result = RagResult(question=payload.question, knowledge_scopes=scopes, chunks=chunks, prompt=prompt)
rag_result = await KnowledgeAgentService.build_result(
db,
question=payload.question,
version_overrides=payload.knowledgeVersions,
preview_knowledge_ids=payload.knowledgeIds,
)
rag_result = RagResult(
question=rag_result.question,
knowledge_scopes=rag_result.knowledge_scopes,
chunks=rag_result.chunks,
prompt=f"{payload.promptContent.strip()}\n\n{rag_result.prompt}",
allow_general_knowledge=rag_result.allow_general_knowledge,
retrieval_log_id=rag_result.retrieval_log_id,
tool_trace=rag_result.tool_trace,
)
result = ModelClientService.debug_model(
model,
rag_result,
@@ -85,6 +97,8 @@ def debug_agent(
"max_token": payload.maxToken,
},
)
result["retrievalTrace"] = rag_result.tool_trace or []
result["retrievalLogId"] = rag_result.retrieval_log_id
OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="debug", target_id=model.id)
db.commit()
return api_success(result)

View File

@@ -4,6 +4,7 @@ from fastapi import APIRouter
from app.api import (
admin_auth,
admin_agent_records,
admin_dashboard,
admin_knowledge,
admin_knowledge_lifecycle,
@@ -22,6 +23,7 @@ api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(user.router, prefix="/user", tags=["user"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"])
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])

View File

@@ -7,11 +7,13 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class Knowledge(Base, TimestampMixin):
__tablename__ = "sys_knowledge"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
feishu_space_id: Mapped[str] = mapped_column(String(100), nullable=False)
feishu_node_id: Mapped[str] = mapped_column(String(100), nullable=False)
@@ -38,7 +40,7 @@ class Knowledge(Base, TimestampMixin):
class UserKnowledgePermission(Base, TimestampMixin):
__tablename__ = "sys_user_kb"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
@@ -52,7 +54,7 @@ class UserKnowledgePermission(Base, TimestampMixin):
class KnowledgeSourceSnapshot(Base):
__tablename__ = "sys_knowledge_source_snapshot"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
sync_job_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
source_title: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -67,7 +69,7 @@ class KnowledgeVersion(Base):
__tablename__ = "sys_knowledge_version"
__table_args__ = (UniqueConstraint("knowledge_id", "version_no", name="uq_kb_version_no"),)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
snapshot_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_source_snapshot.id"), nullable=False)
version_no: Mapped[int] = mapped_column(Integer, nullable=False)
@@ -86,7 +88,7 @@ class KnowledgeVersion(Base):
class KnowledgeManifest(Base):
__tablename__ = "sys_knowledge_manifest"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
purpose: Mapped[str] = mapped_column(Text, nullable=False)
@@ -105,7 +107,7 @@ class KnowledgeManifest(Base):
class KnowledgeSection(Base):
__tablename__ = "sys_knowledge_section"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
section_key: Mapped[str] = mapped_column(String(64), nullable=False)
@@ -120,7 +122,7 @@ class KnowledgeSection(Base):
class KnowledgeChunk(Base):
__tablename__ = "sys_knowledge_chunk"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
section_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_section.id"), index=True, nullable=False)
@@ -138,7 +140,7 @@ class KnowledgeChunk(Base):
class KnowledgeCard(Base):
__tablename__ = "sys_knowledge_card"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
section_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_section.id"), index=True, nullable=False)
@@ -161,7 +163,7 @@ class KnowledgeCard(Base):
class KnowledgeSyncJob(Base):
__tablename__ = "sys_knowledge_sync_job"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
mode: Mapped[str] = mapped_column(String(30), nullable=False)
status: Mapped[str] = mapped_column(String(30), index=True, nullable=False)
@@ -178,7 +180,7 @@ class KnowledgeSyncJob(Base):
class KnowledgeReviewRecord(Base):
__tablename__ = "sys_knowledge_review_record"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
card_id: Mapped[int | None] = mapped_column(ForeignKey("sys_knowledge_card.id"), nullable=True)
@@ -191,7 +193,7 @@ class KnowledgeReviewRecord(Base):
class KnowledgePublishLog(Base):
__tablename__ = "sys_knowledge_publish_log"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
from_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
to_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
@@ -205,7 +207,7 @@ class KnowledgePublishLog(Base):
class KnowledgeRetrievalLog(Base):
__tablename__ = "sys_knowledge_retrieval_log"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
@@ -231,7 +233,7 @@ class KnowledgeRetrievalLog(Base):
class KnowledgeRetrievalCandidate(Base):
__tablename__ = "sys_knowledge_retrieval_candidate"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
retrieval_log_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_retrieval_log.id"), index=True, nullable=False)
knowledge_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
version_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
@@ -246,7 +248,7 @@ class KnowledgeRetrievalCandidate(Base):
class HumanAttentionRecord(Base):
__tablename__ = "sys_human_attention_record"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
message_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
user_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
@@ -264,7 +266,7 @@ class HumanAttentionRecord(Base):
class HumanAttentionHistory(Base):
__tablename__ = "sys_human_attention_history"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
attention_id: Mapped[int] = mapped_column(ForeignKey("sys_human_attention_record.id"), index=True, nullable=False)
from_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
to_status: Mapped[str] = mapped_column(String(20), nullable=False)

View File

@@ -82,6 +82,7 @@ class AgentDebugRequest(BaseModel):
promptContent: str = Field(min_length=1)
modelId: int = Field(gt=0)
knowledgeIds: list[int] = Field(default_factory=list)
knowledgeVersions: dict[int, int] = Field(default_factory=dict)
question: str = Field(min_length=1, max_length=2000)
temperature: float | None = Field(default=None, ge=0, le=2)
topP: float | None = Field(default=None, ge=0, le=1)

View File

@@ -11,10 +11,12 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.chat import ChatMessage
from app.models.knowledge import KnowledgeRetrievalLog
from app.models.user import User
from app.services.ai_request_log_service import AiRequestLogService
from app.services.chat_service import ChatService, _title_from_question
from app.services.external_errors import ExternalServiceError
from app.services.human_attention_service import HumanAttentionService
from app.services.model_stream_service import ModelStreamService
from app.services.rag_async_service import AsyncRagService
from app.services.rag_service import RagService
@@ -83,6 +85,7 @@ class ChatStreamService:
error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
)
_mark_retrieval_failed(db, rag_result, str(exc), cost_ms)
db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -102,6 +105,7 @@ class ChatStreamService:
error_message="模型未返回有效内容",
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
)
_mark_retrieval_failed(db, rag_result, "模型未返回有效内容", cost_ms)
db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
@@ -188,6 +192,7 @@ class ChatStreamService:
normalized_question,
history=history,
session_summary=getattr(session, "summary", None),
session_id=session.id,
)
model_response = ModelStreamService.stream_async(db, rag_result)
async for chunk in model_response.chunks:
@@ -304,9 +309,52 @@ def _write_success(
cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
)
if rag_result is not None and rag_result.retrieval_log_id:
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)
if retrieval_log:
attention = HumanAttentionService.create_if_needed(
db,
session_id=session.id,
message_id=user_message_id_for_attention(db, session.id, assistant_message.id),
user_id=user.id,
question=question,
answer=answer,
knowledge_missing=not rag_result.allow_general_knowledge and not rag_result.is_hit,
)
retrieval_log.message_id = assistant_message.id
retrieval_log.final_answer = answer
retrieval_log.status = "success"
retrieval_log.total_cost_ms = cost_ms
retrieval_log.attention_created = 1 if attention else 0
db.add(retrieval_log)
db.commit()
def user_message_id_for_attention(db: Session, session_id: int, assistant_message_id: int) -> int:
message_id = db.scalar(
select(ChatMessage.id)
.where(
ChatMessage.session_id == session_id,
ChatMessage.role == "user",
ChatMessage.id < assistant_message_id,
)
.order_by(ChatMessage.id.desc())
.limit(1)
)
return int(message_id or assistant_message_id)
def _mark_retrieval_failed(db: Session, rag_result, error_message: str, cost_ms: int) -> None:
if rag_result is None or not rag_result.retrieval_log_id:
return
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)
if retrieval_log:
retrieval_log.status = "failed"
retrieval_log.error_message = error_message
retrieval_log.total_cost_ms = cost_ms
db.add(retrieval_log)
def _build_rag_result(db: Session, user: User, question: str, history: list[ChatMessage], summary: str | None):
parameters = signature(RagService.build_result).parameters
if "history" in parameters:

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models.knowledge import HumanAttentionHistory, HumanAttentionRecord
URGENT_TERMS = ("自杀", "不想活", "自伤", "伤害别人", "杀人", "现实危险")
IMPORTANT_TERMS = ("绝望", "撑不住", "崩溃", "非常痛苦", "反复失败", "没有办法")
CONTACT_TERMS = ("联系老师", "找老师", "人工帮助", "人工客服")
class HumanAttentionService:
@staticmethod
def create_if_needed(
db: Session,
*,
session_id: int,
message_id: int,
user_id: int,
question: str,
answer: str,
knowledge_missing: bool,
) -> HumanAttentionRecord | None:
priority = None
reason = None
if any(term in question for term in URGENT_TERMS):
priority, reason = "urgent", "检测到现实危险或自伤伤人风险"
elif any(term in question for term in IMPORTANT_TERMS):
priority, reason = "important", "用户表达持续或强烈痛苦"
elif any(term in question for term in CONTACT_TERMS):
priority, reason = "normal", "用户主动要求联系老师或人工"
elif knowledge_missing:
priority, reason = "normal", "课程或业务问题缺少可靠正式知识"
if priority is None:
return None
record = HumanAttentionRecord(
session_id=session_id,
message_id=message_id,
user_id=user_id,
trigger_message=question,
problem_summary=_summary(question),
trigger_reason=reason,
priority=priority,
status="pending",
)
db.add(record)
db.flush()
db.add(
HumanAttentionHistory(
attention_id=record.id,
from_status=None,
to_status="pending",
note=f"系统自动创建;回答摘要:{_summary(answer, 200)}",
operated_by=0,
)
)
return record
def _summary(text: str, limit: int = 120) -> str:
value = " ".join(text.split())
return value if len(value) <= limit else value[:limit].rstrip() + ""

View File

@@ -0,0 +1,293 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import math
import re
from dataclasses import dataclass
from time import perf_counter
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.ai_config import ModelConfig
from app.models.knowledge import (
Knowledge,
KnowledgeChunk,
KnowledgeManifest,
KnowledgeRetrievalCandidate,
KnowledgeRetrievalLog,
KnowledgeSection,
KnowledgeVersion,
)
from app.services.knowledge_pipeline_service import expand_synonyms, extract_terms
from app.services.knowledge_service import KnowledgeScope
from app.services.model_service import _call_configured_model, _system_config_bool
from app.services.rag_service import PromptService, RagResult, RetrievedChunk
SAFETY_RULE_VERSION = "minimum-safety-v1"
MAX_LEXICAL_CANDIDATES = 12
MAX_SELECTED_SECTIONS = 4
BUSINESS_MARKERS = {"课程", "大本营", "训练营", "老师", "卢慧", "功课", "学员", "课堂", "练习", "觉察", "内在"}
@dataclass
class Candidate:
chunk: KnowledgeChunk
section: KnowledgeSection
knowledge: Knowledge
version: KnowledgeVersion
lexical_score: float
rerank_score: float | None = None
selected: bool = False
discard_reason: str | None = None
class KnowledgeAgentService:
@classmethod
async def build_result(
cls,
db: Session,
*,
question: str,
history=None,
session_summary: str | None = None,
session_id: int | None = None,
user_id: int | None = None,
version_overrides: dict[int, int] | None = None,
preview_knowledge_ids: list[int] | None = None,
) -> RagResult:
started = perf_counter()
catalog = cls.get_knowledge_catalog(
db, version_overrides=version_overrides, preview_knowledge_ids=preview_knowledge_ids
)
catalog_json = json.dumps(catalog, ensure_ascii=False, sort_keys=True, default=str)
log = KnowledgeRetrievalLog(
session_id=session_id,
user_id=user_id,
question=question.strip(),
catalog_snapshot=catalog_json,
catalog_hash=hashlib.sha256(catalog_json.encode()).hexdigest(),
safety_rule_version=SAFETY_RULE_VERSION,
status="running",
)
db.add(log)
db.flush()
scopes = [KnowledgeScope(id=item["knowledgeId"], name=item["name"], feishu_space_id="", feishu_node_id="") for item in catalog]
trace: list[dict] = [{"tool": "get_knowledge_catalog", "count": len(catalog)}]
need_knowledge, selected_ids, reason = cls._decide(question, catalog)
trace.append({"tool": "agent_decision", "needKnowledge": need_knowledge, "selectedKnowledgeIds": selected_ids, "reason": reason})
chunks: list[RetrievedChunk] = []
try:
if need_knowledge and selected_ids:
terms = cls._query_terms(question)
candidates = cls.search_knowledge(db, terms, selected_ids, catalog)
trace.append({"tool": "search_knowledge", "queryTerms": terms, "candidateCount": len(candidates)})
await cls._rerank(db, question, candidates, trace)
selected = cls._select_sections(candidates)
chunks = [
RetrievedChunk(
knowledge_id=item.knowledge.id,
knowledge_name=item.knowledge.name,
title=item.section.title,
content=cls._protect_content(item.section.content),
version_id=item.version.id,
section_id=item.section.id,
chunk_id=item.chunk.id,
score=item.rerank_score if item.rerank_score is not None else item.lexical_score,
)
for item in selected
]
trace.append({"tool": "read_knowledge_sections", "sectionIds": [item.section.id for item in selected]})
cls._persist_candidates(db, log.id, candidates)
log.knowledge_called = 1
log.selected_knowledge_ids = ",".join(str(item) for item in selected_ids)
log.query_terms = json.dumps(terms, ensure_ascii=False)
log.final_section_ids = ",".join(str(item.section.id) for item in selected)
log.tool_trace = json.dumps(trace, ensure_ascii=False)
log.tool_cost_ms = int((perf_counter() - started) * 1000)
log.status = "prepared"
db.add(log)
return RagResult(
question=question,
knowledge_scopes=scopes,
chunks=chunks,
prompt=PromptService.build_prompt(db, question, chunks, history, session_summary),
allow_general_knowledge=not need_knowledge,
retrieval_log_id=log.id,
tool_trace=trace,
)
except Exception as exc:
log.status = "failed"
log.error_message = str(exc)
log.tool_trace = json.dumps(trace, ensure_ascii=False)
log.tool_cost_ms = int((perf_counter() - started) * 1000)
db.add(log)
raise
@staticmethod
def get_knowledge_catalog(
db: Session,
*,
version_overrides: dict[int, int] | None = None,
preview_knowledge_ids: list[int] | None = None,
) -> list[dict]:
query = select(Knowledge).where(Knowledge.lifecycle_status == "active")
if preview_knowledge_ids is None:
query = query.where(
Knowledge.status == 1,
Knowledge.current_version_id.is_not(None),
Knowledge.manifest_confirmed == 1,
Knowledge.source_status == "normal",
)
elif preview_knowledge_ids:
query = query.where(Knowledge.id.in_(preview_knowledge_ids))
rows = db.scalars(query.order_by(Knowledge.id)).all()
catalog: list[dict] = []
for knowledge in rows:
version_id = (version_overrides or {}).get(knowledge.id) or knowledge.current_version_id or knowledge.pending_version_id
if not version_id:
continue
version = db.get(KnowledgeVersion, version_id)
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version_id))
if version is None or manifest is None:
continue
catalog.append(
{
"knowledgeId": knowledge.id,
"name": knowledge.name,
"type": knowledge.knowledge_type,
"purpose": manifest.purpose,
"applicableQuestions": manifest.applicable_questions,
"inapplicableQuestions": manifest.inapplicable_questions,
"coreTopics": manifest.core_topics,
"boundaries": manifest.boundaries,
"versionId": version.id,
"versionNo": version.version_no,
"publishedAt": version.published_at,
}
)
return catalog
@classmethod
def search_knowledge(cls, db: Session, terms: list[str], selected_ids: list[int], catalog: list[dict]) -> list[Candidate]:
versions_by_kb = {item["knowledgeId"]: item["versionId"] for item in catalog}
version_ids = [versions_by_kb[item] for item in selected_ids if item in versions_by_kb]
if not version_ids:
return []
chunks = db.scalars(select(KnowledgeChunk).where(KnowledgeChunk.version_id.in_(version_ids))).all()
sections = {item.id: item for item in db.scalars(select(KnowledgeSection).where(KnowledgeSection.version_id.in_(version_ids))).all()}
knowledge_rows = {item.id: item for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(selected_ids))).all()}
versions = {item.id: item for item in db.scalars(select(KnowledgeVersion).where(KnowledgeVersion.id.in_(version_ids))).all()}
frequencies = {term: sum(1 for chunk in chunks if term in f"{chunk.title}{chunk.normalized_text}") for term in terms}
candidates: list[Candidate] = []
for chunk in chunks:
section, knowledge, version = sections.get(chunk.section_id), knowledge_rows.get(chunk.knowledge_id), versions.get(chunk.version_id)
if not section or not knowledge or not version:
continue
haystack = f"{chunk.title} {chunk.normalized_text} {chunk.content}".lower()
score = 0.0
for term in terms:
count = haystack.count(term)
if count:
idf = math.log((len(chunks) + 1) / (frequencies.get(term, 0) + 1)) + 1
score += (count / (count + 1.2)) * idf * (2.4 if term in chunk.title.lower() else 1.0)
if score > 0:
candidates.append(Candidate(chunk, section, knowledge, version, score))
candidates.sort(key=lambda item: (item.lexical_score, item.version.published_at or item.version.created_at), reverse=True)
return candidates[:MAX_LEXICAL_CANDIDATES]
@classmethod
async def _rerank(cls, db: Session, question: str, candidates: list[Candidate], trace: list[dict]) -> None:
if not candidates:
return
model = db.scalar(select(ModelConfig).where(ModelConfig.enabled == 1).order_by(ModelConfig.id.desc()).limit(1))
if model is None or _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled):
trace.append({"tool": "model_rerank", "status": "lexical_fallback", "reason": "模型未启用或处于 mock"})
return
prompt = (
"你是知识检索重排器。根据用户问题给候选打0到100相关分只返回JSON"
'{"scores":[{"index":1,"score":90}]}。不要回答问题。\n'
f"用户问题:{question}\n候选:\n"
+ "\n".join(f"[{index}] {item.knowledge.name}/{item.chunk.title}: {item.chunk.content[:350]}" for index, item in enumerate(candidates, 1))
)
rag = RagResult(question=question, knowledge_scopes=[], chunks=[], prompt=prompt, allow_general_knowledge=True)
try:
raw = await asyncio.to_thread(_call_configured_model, model, rag, allow_no_hit=True)
payload = json.loads(_extract_json(raw))
score_map = {int(item["index"]): max(0.0, min(100.0, float(item["score"]))) for item in payload.get("scores", [])}
for index, item in enumerate(candidates, 1):
item.rerank_score = score_map.get(index)
candidates.sort(key=lambda item: item.rerank_score if item.rerank_score is not None else item.lexical_score, reverse=True)
trace.append({"tool": "model_rerank", "status": "success", "candidateCount": len(candidates)})
except Exception as exc:
trace.append({"tool": "model_rerank", "status": "lexical_fallback", "reason": str(exc)[:300]})
@staticmethod
def _select_sections(candidates: list[Candidate]) -> list[Candidate]:
selected: list[Candidate] = []
seen: set[int] = set()
for item in candidates:
score = item.rerank_score if item.rerank_score is not None else item.lexical_score * 10
if score < 8:
item.discard_reason = "相关性不足"
elif item.section.id in seen:
item.discard_reason = "同一父章节已有更高分候选"
elif len(selected) >= MAX_SELECTED_SECTIONS:
item.discard_reason = "超过本轮章节数量限制"
else:
item.selected = True
selected.append(item)
seen.add(item.section.id)
return selected
@staticmethod
def _persist_candidates(db: Session, log_id: int, candidates: list[Candidate]) -> None:
for item in candidates:
db.add(KnowledgeRetrievalCandidate(
retrieval_log_id=log_id,
knowledge_id=item.knowledge.id,
version_id=item.version.id,
chunk_id=item.chunk.id,
section_id=item.section.id,
lexical_score=f"{item.lexical_score:.6f}",
rerank_score=f"{item.rerank_score:.4f}" if item.rerank_score is not None else None,
selected=1 if item.selected else 0,
discard_reason=item.discard_reason,
))
@staticmethod
def _decide(question: str, catalog: list[dict]) -> tuple[bool, list[int], str]:
business = any(marker in question for marker in BUSINESS_MARKERS)
if not catalog:
return business, [], "当前没有可用正式知识库"
terms = set(extract_terms(question))
ranked: list[tuple[float, int]] = []
for item in catalog:
manifest_terms = set(extract_terms(" ".join(str(item.get(key, "")) for key in ("name", "purpose", "applicableQuestions", "coreTopics"))))
overlap = len(terms & manifest_terms) / max(1, min(len(terms), 18))
if overlap > 0 or business:
ranked.append((overlap, int(item["knowledgeId"])))
ranked.sort(reverse=True)
selected = [item_id for score, item_id in ranked if score >= 0.04][:4]
if business and not selected:
selected = [item_id for _, item_id in ranked[:3]]
return business or bool(selected), selected, "涉及课程/老师/业务知识" if business else "问题与知识目录主题相关"
@staticmethod
def _query_terms(question: str) -> list[str]:
base = extract_terms(question)
return list(dict.fromkeys(base + expand_synonyms(base)))[:40]
@staticmethod
def _protect_content(content: str) -> str:
return content if len(content) <= 3200 else content[:3200].rstrip() + "\n[章节内容已按保护规则截断]"
def _extract_json(value: str) -> str:
match = re.search(r"\{[\s\S]*\}", value)
if not match:
raise ValueError("重排模型未返回 JSON")
return match.group(0)

View File

@@ -37,7 +37,7 @@ class ModelClientService:
if model is None:
raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model")
model_name = model.model_name
answer = _call_configured_model(model, rag_result)
answer = _call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge)
return ModelCompletion(
answer=answer,
model_id=model.id if model is not None else None,
@@ -185,7 +185,7 @@ def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) ->
payload = {
"model": model.model_name,
"messages": [
{"role": "system", "content": "你是大本营答疑助手,只能基于已提供的知识片段回答。"},
{"role": "system", "content": _agent_system_prompt()},
{"role": "user", "content": rag_result.prompt},
],
"stream": False,
@@ -218,7 +218,7 @@ def _call_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> str:
payload = {
"model": model.model_name,
"max_tokens": model.max_token or 1024,
"system": "你是大本营答疑助手,只能基于已提供的知识片段回答。",
"system": _agent_system_prompt(),
"messages": [{"role": "user", "content": rag_result.prompt}],
"stream": False,
}
@@ -252,7 +252,7 @@ def _call_gemini_generate_content(model: ModelConfig, rag_result: RagResult) ->
payload = {
"systemInstruction": {
"parts": [{"text": "你是大本营答疑助手,只能基于已提供的知识片段回答。"}]
"parts": [{"text": _agent_system_prompt()}]
},
"contents": [{"role": "user", "parts": [{"text": rag_result.prompt}]}],
}
@@ -337,7 +337,7 @@ def _call_minimax(model: ModelConfig, rag_result: RagResult) -> str:
"bot_setting": [
{
"bot_name": "大本营答疑助手",
"content": "你是大本营答疑助手,只能基于已提供的知识片段回答。",
"content": _agent_system_prompt(),
}
],
}
@@ -435,6 +435,13 @@ def _model_api_key(model: ModelConfig) -> str:
return SecretService.decrypt(model.api_key)
def _agent_system_prompt() -> str:
return (
"你是大本营答疑助手。必须遵守最低安全规则。涉及课程、老师观点和公司业务时只能依据提供的正式知识,"
"没有可靠知识必须明确依据不足;普通常识可以谨慎回答。不得编造老师观点,不得输出整篇课程资料或大段连续原文。"
)
def _put_if_not_none(target: dict[str, Any], key: str, value: Any) -> None:
if value is not None:
target[key] = value

View File

@@ -15,6 +15,7 @@ from app.models.ai_config import ModelConfig
from app.services.external_errors import ExternalServiceError
from app.services.model_service import (
_anthropic_headers,
_agent_system_prompt,
_auth_headers,
_call_configured_model,
_decimal_to_float,
@@ -60,7 +61,7 @@ class ModelStreamService:
chunks=_chunk_text(_mock_answer(rag_result)),
)
if not rag_result.is_hit:
if not rag_result.is_hit and not rag_result.allow_general_knowledge:
return StreamingModelResponse(
model_id=model.id if model is not None else None,
model_name=model.model_name if model is not None else "no-hit",
@@ -94,7 +95,7 @@ class ModelStreamService:
chunks=_async_chunk_text(_mock_answer(rag_result)),
)
if not rag_result.is_hit:
if not rag_result.is_hit and not rag_result.allow_general_knowledge:
return AsyncStreamingModelResponse(
model_id=model.id if model is not None else None,
model_name=model.model_name if model is not None else "no-hit",
@@ -127,18 +128,20 @@ def _get_enabled_model(db: Session) -> ModelConfig | None:
def _stream_configured_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:
api_type = model.api_type or "openai_compatible"
if model.stream_enabled != 1:
return _chunk_text(_call_configured_model(model, rag_result))
return _chunk_text(_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge))
if api_type == "anthropic_messages":
return _stream_anthropic_messages(model, rag_result)
if api_type == "openai_compatible":
return _stream_openai_compatible_model(model, rag_result)
return _chunk_text(_call_configured_model(model, rag_result))
return _chunk_text(_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge))
async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResult) -> AsyncIterator[str]:
api_type = model.api_type or "openai_compatible"
if model.stream_enabled != 1:
answer = await asyncio.to_thread(_call_configured_model, model, rag_result)
answer = await asyncio.to_thread(
_call_configured_model, model, rag_result, allow_no_hit=rag_result.allow_general_knowledge
)
async for chunk in _async_chunk_text(answer):
yield chunk
return
@@ -153,7 +156,9 @@ async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResu
yield chunk
return
answer = await asyncio.to_thread(_call_configured_model, model, rag_result)
answer = await asyncio.to_thread(
_call_configured_model, model, rag_result, allow_no_hit=rag_result.allow_general_knowledge
)
async for chunk in _async_chunk_text(answer):
yield chunk
@@ -162,7 +167,7 @@ def _stream_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -
payload: dict[str, Any] = {
"model": model.model_name,
"messages": [
{"role": "system", "content": "你是大本营答疑助手,只能基于已提供的知识片段回答。"},
{"role": "system", "content": _agent_system_prompt()},
{"role": "user", "content": rag_result.prompt},
],
"max_tokens": model.max_token or 1024,
@@ -211,7 +216,7 @@ def _openai_stream_payload(model: ModelConfig, rag_result: RagResult) -> dict[st
payload: dict[str, Any] = {
"model": model.model_name,
"messages": [
{"role": "system", "content": "你是大本营答疑助手,只能基于已提供的知识片段回答。"},
{"role": "system", "content": _agent_system_prompt()},
{"role": "user", "content": rag_result.prompt},
],
"max_tokens": model.max_token or 1024,
@@ -317,7 +322,7 @@ def _stream_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> Ite
payload: dict[str, Any] = {
"model": model.model_name,
"max_tokens": model.max_token or 1024,
"system": "你是大本营答疑助手,只能基于已提供的知识片段回答。",
"system": _agent_system_prompt(),
"messages": [{"role": "user", "content": rag_result.prompt}],
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
@@ -361,7 +366,7 @@ def _anthropic_stream_payload(model: ModelConfig, rag_result: RagResult) -> dict
payload: dict[str, Any] = {
"model": model.model_name,
"max_tokens": model.max_token or 1024,
"system": "你是大本营答疑助手,只能基于已提供的知识片段回答。",
"system": _agent_system_prompt(),
"messages": [{"role": "user", "content": rag_result.prompt}],
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))

View File

@@ -1,14 +1,11 @@
from __future__ import annotations
from inspect import signature
from sqlalchemy.orm import Session
from app.models.chat import ChatMessage
from app.models.user import User
from app.services.feishu_async_service import AsyncFeishuKnowledgeService
from app.services.knowledge_service import KnowledgeAccessService
from app.services.rag_service import PromptService, RagResult
from app.services.knowledge_agent_service import KnowledgeAgentService
from app.services.rag_service import RagResult
class AsyncRagService:
@@ -19,21 +16,13 @@ class AsyncRagService:
question: str,
history: list[ChatMessage] | None = None,
session_summary: str | None = None,
session_id: int | None = None,
) -> RagResult:
scopes = KnowledgeAccessService.get_allowed_knowledge(db, user)
chunks = await AsyncFeishuKnowledgeService.retrieve(question, scopes, db)
prompt = _build_prompt(db, question, chunks, history, session_summary)
return RagResult(question=question, knowledge_scopes=scopes, chunks=chunks, prompt=prompt)
def _build_prompt(
db: Session,
question: str,
chunks,
history: list[ChatMessage] | None,
session_summary: str | None,
) -> str:
parameters = signature(PromptService.build_prompt).parameters
if "history" in parameters:
return PromptService.build_prompt(db, question, chunks, history, session_summary)
return PromptService.build_prompt(db, question, chunks)
return await KnowledgeAgentService.build_result(
db,
question=question,
history=history,
session_summary=session_summary,
session_id=session_id,
user_id=user.id,
)

View File

@@ -26,6 +26,10 @@ class RetrievedChunk:
title: str
content: str
source_url: str | None = None
version_id: int | None = None
section_id: int | None = None
chunk_id: int | None = None
score: float | None = None
@dataclass(frozen=True)
@@ -34,6 +38,9 @@ class RagResult:
knowledge_scopes: list[KnowledgeScope]
chunks: list[RetrievedChunk]
prompt: str
allow_general_knowledge: bool = False
retrieval_log_id: int | None = None
tool_trace: list[dict] | None = None
@property
def is_hit(self) -> bool:
@@ -41,7 +48,8 @@ class RagResult:
@property
def knowledge_ids(self) -> str:
return ",".join(str(scope.id) for scope in self.knowledge_scopes)
ids = list(dict.fromkeys(chunk.knowledge_id for chunk in self.chunks))
return ",".join(str(item) for item in ids)
class RagService:
@@ -61,8 +69,7 @@ class RagService:
class PromptService:
_default_prompt = (
"你是大本营答疑助手。你只能基于提供的知识片段回答,不能编造。"
"如果知识片段之间有冲突,需要说明不同来源的观点。"
"你是大本营答疑助手。必须遵守系统安全边界,涉及课程、老师观点和公司业务时只能依据可靠知识,不能编造。"
)
@classmethod
@@ -78,13 +85,18 @@ class PromptService:
# 知识库上下文
context = "\n\n".join(
f"[知识片段 {index}] 来源:{chunk.knowledge_name} / {chunk.title}\n{chunk.content}"
f"[已回读完整章节 {index}] {chunk.title}\n{chunk.content}"
for index, chunk in enumerate(chunks, start=1)
)
if not context:
context = "未检索到相关知识片段"
context = "本轮没有可靠的正式知识章节。对于一般常识可以谨慎回答;涉及课程、老师观点或公司业务时必须说明依据不足,不得编造"
parts = [prompt]
parts.append(
"[不可关闭的最低安全规则 v1]\n"
"现实危险、自伤伤人风险应优先建议立即寻求线下专业帮助;医疗、法律、财务问题不得给出替代专业意见的结论;"
"不得伪造老师观点或课程内容;不得输出整篇课程文章、大段连续原文,也不得通过多轮拼接还原完整资料。"
)
# 对话历史:摘要 + 最近 N 轮原文
history_part = cls._build_history_section(history, session_summary)

View File

@@ -0,0 +1,138 @@
from __future__ import annotations
import asyncio
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.models import Base
from app.models.knowledge import (
Knowledge,
KnowledgeChunk,
KnowledgeManifest,
KnowledgeSection,
KnowledgeSourceSnapshot,
KnowledgeVersion,
)
from app.services.knowledge_agent_service import KnowledgeAgentService
def _database() -> Session:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
return Session(engine)
def _add_published_knowledge(db: Session, *, knowledge_id: int, name: str, open_status: int = 1) -> Knowledge:
knowledge = Knowledge(
id=knowledge_id,
name=name,
feishu_space_id="space",
feishu_node_id=f"node-{knowledge_id}",
status=open_status,
source_status="normal",
manifest_confirmed=1,
knowledge_type="course",
review_mode="manual",
)
db.add(knowledge)
db.flush()
snapshot = KnowledgeSourceSnapshot(
knowledge_id=knowledge.id,
source_title=name,
source_content="家长沟通需要先稳定自己的情绪,再倾听孩子的真实困难。",
content_hash=f"hash-{knowledge_id}",
source_identifier=f"space/node-{knowledge_id}",
)
db.add(snapshot)
db.flush()
version = KnowledgeVersion(
knowledge_id=knowledge.id,
snapshot_id=snapshot.id,
version_no=1,
status="published",
quality_status="passed",
processing_rule_version="test-v1",
)
db.add(version)
db.flush()
knowledge.current_version_id = version.id
db.add(
KnowledgeManifest(
knowledge_id=knowledge.id,
version_id=version.id,
purpose="解答家长与孩子沟通、学习动力相关课程问题",
applicable_questions="孩子学习动力、亲子沟通",
inapplicable_questions="天气和交通",
core_topics="家长情绪、倾听孩子、学习动力",
boundaries="不输出完整课程资料",
content_hash=f"manifest-{knowledge_id}",
confirmed=1,
)
)
section = KnowledgeSection(
knowledge_id=knowledge.id,
version_id=version.id,
section_key="S0001",
title="家长沟通的第一步",
content="家长和学习动力不足的孩子沟通时,第一步是先稳定自己的焦虑,再倾听孩子遇到的具体困难。",
source_start=0,
source_end=44,
sort_order=1,
content_hash=f"section-{knowledge_id}",
)
db.add(section)
db.flush()
db.add(
KnowledgeChunk(
knowledge_id=knowledge.id,
version_id=version.id,
section_id=section.id,
title=section.title,
content=section.content,
normalized_text="家长 沟通 学习 动力 孩子 焦虑 倾听",
keywords='["家长","沟通","学习动力"]',
synonyms='["父母","交流"]',
source_start=0,
source_end=44,
sort_order=1,
content_hash=f"chunk-{knowledge_id}",
)
)
db.commit()
return knowledge
def test_catalog_only_contains_open_published_knowledge():
with _database() as db:
_add_published_knowledge(db, knowledge_id=1, name="开放课程", open_status=1)
_add_published_knowledge(db, knowledge_id=2, name="关闭课程", open_status=0)
catalog = KnowledgeAgentService.get_knowledge_catalog(db)
assert [item["knowledgeId"] for item in catalog] == [1]
def test_common_question_skips_knowledge_tools():
with _database() as db:
_add_published_knowledge(db, knowledge_id=1, name="亲子课程")
result = asyncio.run(KnowledgeAgentService.build_result(db, question="今天北京天气怎么样?"))
assert result.allow_general_knowledge is True
assert result.chunks == []
assert result.tool_trace[1]["needKnowledge"] is False
def test_course_question_without_catalog_does_not_fall_back_to_general_knowledge():
with _database() as db:
result = asyncio.run(KnowledgeAgentService.build_result(db, question="卢慧老师课程里怎么解释亲子沟通?"))
assert result.allow_general_knowledge is False
assert result.chunks == []
def test_course_question_searches_chunk_and_reads_parent_section():
with _database() as db:
_add_published_knowledge(db, knowledge_id=1, name="亲子课程")
result = asyncio.run(KnowledgeAgentService.build_result(db, question="课程里家长怎么和学习动力不足的孩子沟通?"))
assert result.allow_general_knowledge is False
assert len(result.chunks) == 1
assert "先稳定自己的焦虑" in result.chunks[0].content
assert any(item["tool"] == "read_knowledge_sections" for item in result.tool_trace)