feat: 切换Agent自主知识工具检索链路
This commit is contained in:
203
ai_knowledge_base_v2/apps/backend/app/api/admin_agent_records.py
Normal file
203
ai_knowledge_base_v2/apps/backend/app/api/admin_agent_records.py
Normal 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
|
||||
@@ -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="仅超级管理员可以执行此操作")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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() + "…"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
138
ai_knowledge_base_v2/apps/backend/tests/test_knowledge_agent.py
Normal file
138
ai_knowledge_base_v2/apps/backend/tests/test_knowledge_agent.py
Normal 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)
|
||||
Reference in New Issue
Block a user