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]}) 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: def _require_super_admin(admin: Admin) -> None:
if admin.role is not None and admin.role.code != "super_admin": if admin.role is not None and admin.role.code != "super_admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可以执行此操作") 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.admin_service import OperationLogService
from app.services.feishu_service import FeishuKnowledgeService 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.knowledge_service import KnowledgeScope
from app.services.model_service import ModelClientService from app.services.model_service import ModelClientService
from app.services.rag_service import RagResult 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") @router.post("/agent/debug")
def debug_agent( async def debug_agent(
payload: AgentDebugRequest, payload: AgentDebugRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
@@ -69,10 +70,21 @@ def debug_agent(
if model is None: if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
scopes = _agent_knowledge_scopes(db, payload.knowledgeIds) rag_result = await KnowledgeAgentService.build_result(
chunks = FeishuKnowledgeService.retrieve(payload.question, scopes, db) db,
prompt = _build_agent_debug_prompt(payload.promptContent, payload.question, chunks) question=payload.question,
rag_result = RagResult(question=payload.question, knowledge_scopes=scopes, chunks=chunks, prompt=prompt) 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( result = ModelClientService.debug_model(
model, model,
rag_result, rag_result,
@@ -85,6 +97,8 @@ def debug_agent(
"max_token": payload.maxToken, "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) OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="debug", target_id=model.id)
db.commit() db.commit()
return api_success(result) return api_success(result)

View File

@@ -4,6 +4,7 @@ from fastapi import APIRouter
from app.api import ( from app.api import (
admin_auth, admin_auth,
admin_agent_records,
admin_dashboard, admin_dashboard,
admin_knowledge, admin_knowledge,
admin_knowledge_lifecycle, 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(user.router, prefix="/user", tags=["user"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) 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_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_dashboard.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_users.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"]) 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 from app.models.base import Base, TimestampMixin
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class Knowledge(Base, TimestampMixin): class Knowledge(Base, TimestampMixin):
__tablename__ = "sys_knowledge" __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) name: Mapped[str] = mapped_column(String(100), nullable=False)
feishu_space_id: 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) feishu_node_id: Mapped[str] = mapped_column(String(100), nullable=False)
@@ -38,7 +40,7 @@ class Knowledge(Base, TimestampMixin):
class UserKnowledgePermission(Base, TimestampMixin): class UserKnowledgePermission(Base, TimestampMixin):
__tablename__ = "sys_user_kb" __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) 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) knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
@@ -52,7 +54,7 @@ class UserKnowledgePermission(Base, TimestampMixin):
class KnowledgeSourceSnapshot(Base): class KnowledgeSourceSnapshot(Base):
__tablename__ = "sys_knowledge_source_snapshot" __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) 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) sync_job_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
source_title: Mapped[str] = mapped_column(String(255), nullable=False) source_title: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -67,7 +69,7 @@ class KnowledgeVersion(Base):
__tablename__ = "sys_knowledge_version" __tablename__ = "sys_knowledge_version"
__table_args__ = (UniqueConstraint("knowledge_id", "version_no", name="uq_kb_version_no"),) __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) 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) snapshot_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_source_snapshot.id"), nullable=False)
version_no: Mapped[int] = mapped_column(Integer, nullable=False) version_no: Mapped[int] = mapped_column(Integer, nullable=False)
@@ -86,7 +88,7 @@ class KnowledgeVersion(Base):
class KnowledgeManifest(Base): class KnowledgeManifest(Base):
__tablename__ = "sys_knowledge_manifest" __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) 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) version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
purpose: Mapped[str] = mapped_column(Text, nullable=False) purpose: Mapped[str] = mapped_column(Text, nullable=False)
@@ -105,7 +107,7 @@ class KnowledgeManifest(Base):
class KnowledgeSection(Base): class KnowledgeSection(Base):
__tablename__ = "sys_knowledge_section" __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) 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) 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) section_key: Mapped[str] = mapped_column(String(64), nullable=False)
@@ -120,7 +122,7 @@ class KnowledgeSection(Base):
class KnowledgeChunk(Base): class KnowledgeChunk(Base):
__tablename__ = "sys_knowledge_chunk" __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) 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) 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) 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): class KnowledgeCard(Base):
__tablename__ = "sys_knowledge_card" __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) 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) 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) 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): class KnowledgeSyncJob(Base):
__tablename__ = "sys_knowledge_sync_job" __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) knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
mode: Mapped[str] = mapped_column(String(30), nullable=False) mode: Mapped[str] = mapped_column(String(30), nullable=False)
status: Mapped[str] = mapped_column(String(30), index=True, nullable=False) status: Mapped[str] = mapped_column(String(30), index=True, nullable=False)
@@ -178,7 +180,7 @@ class KnowledgeSyncJob(Base):
class KnowledgeReviewRecord(Base): class KnowledgeReviewRecord(Base):
__tablename__ = "sys_knowledge_review_record" __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) 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) 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) card_id: Mapped[int | None] = mapped_column(ForeignKey("sys_knowledge_card.id"), nullable=True)
@@ -191,7 +193,7 @@ class KnowledgeReviewRecord(Base):
class KnowledgePublishLog(Base): class KnowledgePublishLog(Base):
__tablename__ = "sys_knowledge_publish_log" __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) 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) from_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
to_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): class KnowledgeRetrievalLog(Base):
__tablename__ = "sys_knowledge_retrieval_log" __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) session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
message_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) user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
@@ -231,7 +233,7 @@ class KnowledgeRetrievalLog(Base):
class KnowledgeRetrievalCandidate(Base): class KnowledgeRetrievalCandidate(Base):
__tablename__ = "sys_knowledge_retrieval_candidate" __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) 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) knowledge_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
version_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): class HumanAttentionRecord(Base):
__tablename__ = "sys_human_attention_record" __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) session_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
message_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) user_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
@@ -264,7 +266,7 @@ class HumanAttentionRecord(Base):
class HumanAttentionHistory(Base): class HumanAttentionHistory(Base):
__tablename__ = "sys_human_attention_history" __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) 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) from_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
to_status: Mapped[str] = mapped_column(String(20), nullable=False) 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) promptContent: str = Field(min_length=1)
modelId: int = Field(gt=0) modelId: int = Field(gt=0)
knowledgeIds: list[int] = Field(default_factory=list) knowledgeIds: list[int] = Field(default_factory=list)
knowledgeVersions: dict[int, int] = Field(default_factory=dict)
question: str = Field(min_length=1, max_length=2000) question: str = Field(min_length=1, max_length=2000)
temperature: float | None = Field(default=None, ge=0, le=2) temperature: float | None = Field(default=None, ge=0, le=2)
topP: float | None = Field(default=None, ge=0, le=1) 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 sqlalchemy.orm import Session
from app.models.chat import ChatMessage from app.models.chat import ChatMessage
from app.models.knowledge import KnowledgeRetrievalLog
from app.models.user import User from app.models.user import User
from app.services.ai_request_log_service import AiRequestLogService from app.services.ai_request_log_service import AiRequestLogService
from app.services.chat_service import ChatService, _title_from_question from app.services.chat_service import ChatService, _title_from_question
from app.services.external_errors import ExternalServiceError 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.model_stream_service import ModelStreamService
from app.services.rag_async_service import AsyncRagService from app.services.rag_async_service import AsyncRagService
from app.services.rag_service import RagService from app.services.rag_service import RagService
@@ -83,6 +85,7 @@ class ChatStreamService:
error_message=str(exc), error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None, 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() db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -102,6 +105,7 @@ class ChatStreamService:
error_message="模型未返回有效内容", error_message="模型未返回有效内容",
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
) )
_mark_retrieval_failed(db, rag_result, "模型未返回有效内容", cost_ms)
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
@@ -188,6 +192,7 @@ class ChatStreamService:
normalized_question, normalized_question,
history=history, history=history,
session_summary=getattr(session, "summary", None), session_summary=getattr(session, "summary", None),
session_id=session.id,
) )
model_response = ModelStreamService.stream_async(db, rag_result) model_response = ModelStreamService.stream_async(db, rag_result)
async for chunk in model_response.chunks: async for chunk in model_response.chunks:
@@ -304,9 +309,52 @@ def _write_success(
cost_ms=cost_ms, cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks if rag_result is not None else None, 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() 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): def _build_rag_result(db: Session, user: User, question: str, history: list[ChatMessage], summary: str | None):
parameters = signature(RagService.build_result).parameters parameters = signature(RagService.build_result).parameters
if "history" in 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: if model is None:
raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model") raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model")
model_name = model.model_name 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( return ModelCompletion(
answer=answer, answer=answer,
model_id=model.id if model is not None else None, 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 = { payload = {
"model": model.model_name, "model": model.model_name,
"messages": [ "messages": [
{"role": "system", "content": "你是大本营答疑助手,只能基于已提供的知识片段回答。"}, {"role": "system", "content": _agent_system_prompt()},
{"role": "user", "content": rag_result.prompt}, {"role": "user", "content": rag_result.prompt},
], ],
"stream": False, "stream": False,
@@ -218,7 +218,7 @@ def _call_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> str:
payload = { payload = {
"model": model.model_name, "model": model.model_name,
"max_tokens": model.max_token or 1024, "max_tokens": model.max_token or 1024,
"system": "你是大本营答疑助手,只能基于已提供的知识片段回答。", "system": _agent_system_prompt(),
"messages": [{"role": "user", "content": rag_result.prompt}], "messages": [{"role": "user", "content": rag_result.prompt}],
"stream": False, "stream": False,
} }
@@ -252,7 +252,7 @@ def _call_gemini_generate_content(model: ModelConfig, rag_result: RagResult) ->
payload = { payload = {
"systemInstruction": { "systemInstruction": {
"parts": [{"text": "你是大本营答疑助手,只能基于已提供的知识片段回答。"}] "parts": [{"text": _agent_system_prompt()}]
}, },
"contents": [{"role": "user", "parts": [{"text": rag_result.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_setting": [
{ {
"bot_name": "大本营答疑助手", "bot_name": "大本营答疑助手",
"content": "你是大本营答疑助手,只能基于已提供的知识片段回答。", "content": _agent_system_prompt(),
} }
], ],
} }
@@ -435,6 +435,13 @@ def _model_api_key(model: ModelConfig) -> str:
return SecretService.decrypt(model.api_key) 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: def _put_if_not_none(target: dict[str, Any], key: str, value: Any) -> None:
if value is not None: if value is not None:
target[key] = value 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.external_errors import ExternalServiceError
from app.services.model_service import ( from app.services.model_service import (
_anthropic_headers, _anthropic_headers,
_agent_system_prompt,
_auth_headers, _auth_headers,
_call_configured_model, _call_configured_model,
_decimal_to_float, _decimal_to_float,
@@ -60,7 +61,7 @@ class ModelStreamService:
chunks=_chunk_text(_mock_answer(rag_result)), 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( return StreamingModelResponse(
model_id=model.id if model is not None else None, model_id=model.id if model is not None else None,
model_name=model.model_name if model is not None else "no-hit", 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)), 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( return AsyncStreamingModelResponse(
model_id=model.id if model is not None else None, model_id=model.id if model is not None else None,
model_name=model.model_name if model is not None else "no-hit", 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]: def _stream_configured_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:
api_type = model.api_type or "openai_compatible" api_type = model.api_type or "openai_compatible"
if model.stream_enabled != 1: 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": if api_type == "anthropic_messages":
return _stream_anthropic_messages(model, rag_result) return _stream_anthropic_messages(model, rag_result)
if api_type == "openai_compatible": if api_type == "openai_compatible":
return _stream_openai_compatible_model(model, rag_result) 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]: async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResult) -> AsyncIterator[str]:
api_type = model.api_type or "openai_compatible" api_type = model.api_type or "openai_compatible"
if model.stream_enabled != 1: 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): async for chunk in _async_chunk_text(answer):
yield chunk yield chunk
return return
@@ -153,7 +156,9 @@ async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResu
yield chunk yield chunk
return 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): async for chunk in _async_chunk_text(answer):
yield chunk yield chunk
@@ -162,7 +167,7 @@ def _stream_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -
payload: dict[str, Any] = { payload: dict[str, Any] = {
"model": model.model_name, "model": model.model_name,
"messages": [ "messages": [
{"role": "system", "content": "你是大本营答疑助手,只能基于已提供的知识片段回答。"}, {"role": "system", "content": _agent_system_prompt()},
{"role": "user", "content": rag_result.prompt}, {"role": "user", "content": rag_result.prompt},
], ],
"max_tokens": model.max_token or 1024, "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] = { payload: dict[str, Any] = {
"model": model.model_name, "model": model.model_name,
"messages": [ "messages": [
{"role": "system", "content": "你是大本营答疑助手,只能基于已提供的知识片段回答。"}, {"role": "system", "content": _agent_system_prompt()},
{"role": "user", "content": rag_result.prompt}, {"role": "user", "content": rag_result.prompt},
], ],
"max_tokens": model.max_token or 1024, "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] = { payload: dict[str, Any] = {
"model": model.model_name, "model": model.model_name,
"max_tokens": model.max_token or 1024, "max_tokens": model.max_token or 1024,
"system": "你是大本营答疑助手,只能基于已提供的知识片段回答。", "system": _agent_system_prompt(),
"messages": [{"role": "user", "content": rag_result.prompt}], "messages": [{"role": "user", "content": rag_result.prompt}],
} }
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature)) _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] = { payload: dict[str, Any] = {
"model": model.model_name, "model": model.model_name,
"max_tokens": model.max_token or 1024, "max_tokens": model.max_token or 1024,
"system": "你是大本营答疑助手,只能基于已提供的知识片段回答。", "system": _agent_system_prompt(),
"messages": [{"role": "user", "content": rag_result.prompt}], "messages": [{"role": "user", "content": rag_result.prompt}],
} }
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature)) _put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))

View File

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

View File

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