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]})
|
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="仅超级管理员可以执行此操作")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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