6737 lines
262 KiB
Python
6737 lines
262 KiB
Python
"""全自适应 RAG 服务。
|
||
|
||
能力:
|
||
1. 意图识别(question / statement / mixed)
|
||
2. 多域路由(结构化 DB + 向量检索)
|
||
3. 回答生成与来源引用
|
||
4. QA 记录、反馈、JSONL 导出
|
||
5. 知识库文档/URL/飞书消息入库向量化
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import ast
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import time
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Optional
|
||
|
||
import httpx
|
||
from sqlalchemy import and_, bindparam, delete, func, or_, select, text
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..config.ai_prompts import PROMPT_RAG_ANSWER, PROMPT_RAG_DB_PROJECT_QA, PROMPT_RAG_INTENT_CLASSIFICATION
|
||
from ..database import AsyncSessionLocal
|
||
from ..models import (
|
||
KnowledgeBaseChunk,
|
||
KnowledgeBaseDoc,
|
||
KGEvidence,
|
||
KGEntity,
|
||
KGRelation,
|
||
Project,
|
||
ProjectSignalLink,
|
||
QAFeedback,
|
||
QARecord,
|
||
QASession,
|
||
QASessionState,
|
||
Signal,
|
||
Task,
|
||
TruthNode,
|
||
User,
|
||
UserProject,
|
||
UserWorkload,
|
||
)
|
||
from .context_cache import context_cache_service
|
||
from .llm import LLMService
|
||
from .signal_source_orchestration_service import SignalSourceOrchestrationService
|
||
from .symbol_index_service import SymbolIndexService
|
||
|
||
|
||
def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
||
raw = str(os.getenv(name, "") or "").strip()
|
||
if not raw:
|
||
value = int(default)
|
||
else:
|
||
try:
|
||
value = int(raw)
|
||
except Exception:
|
||
value = int(default)
|
||
return max(minimum, min(maximum, value))
|
||
|
||
QUESTION_HINTS = (
|
||
"吗",
|
||
"么",
|
||
"呢",
|
||
"呀",
|
||
"啊",
|
||
"?",
|
||
"?",
|
||
"如何",
|
||
"怎么",
|
||
"为什么",
|
||
"为何",
|
||
"谁",
|
||
"哪里",
|
||
"有没有",
|
||
"有无",
|
||
"有吗",
|
||
"是否",
|
||
"是否有",
|
||
"是否存在",
|
||
"哪些",
|
||
"哪个",
|
||
"什么",
|
||
"是什么",
|
||
"多少",
|
||
"几",
|
||
)
|
||
PROJECT_EXISTENCE_RE = re.compile(r"(有没有|是否有|有).*?(项目|系统)|项目.*?(有没有|是否有|有).*(吗|\?)")
|
||
PROJECT_PROGRESS_RE = re.compile(r"(进度|阶段|完成度|完成率|推进|里程碑|状态)")
|
||
QUESTION_NOISE_RE = re.compile(
|
||
r"(目前|当前|现在|请问|一下|系统中|项目中|系统内|项目内|是否|有没有|有无|有|吗|呢|呀|啊|什么|哪个|多少|查询|看看)"
|
||
)
|
||
STATEMENT_HINTS = (
|
||
"需要",
|
||
"请",
|
||
"新增",
|
||
"优化",
|
||
"修复",
|
||
"安排",
|
||
"立项",
|
||
"开发",
|
||
"上线",
|
||
)
|
||
PROJECT_KEYWORDS = (
|
||
"项目",
|
||
"信号",
|
||
"任务",
|
||
"宪章",
|
||
"进度",
|
||
"状态",
|
||
"工作量",
|
||
"负责人",
|
||
)
|
||
PEOPLE_KEYWORDS = (
|
||
"成员",
|
||
"人员",
|
||
"同事",
|
||
"负责人",
|
||
"owner",
|
||
"assignee",
|
||
"谁负责",
|
||
"谁在做",
|
||
"名单",
|
||
)
|
||
DOC_KEYWORDS = (
|
||
"文档",
|
||
"规范",
|
||
"spec",
|
||
"说明",
|
||
"流程",
|
||
"模板",
|
||
"手册",
|
||
"how to",
|
||
"怎么做",
|
||
)
|
||
FEATURE_QUERY_KEYWORDS = (
|
||
"功能",
|
||
"用途",
|
||
"是干嘛",
|
||
"做什么",
|
||
"有什么用",
|
||
"页面",
|
||
"菜单",
|
||
"入口",
|
||
"在哪",
|
||
"ai配置",
|
||
"智能配置",
|
||
"配置中心",
|
||
"能力管理",
|
||
"用户管理",
|
||
"项目列表",
|
||
"团队负载",
|
||
"ai管家",
|
||
)
|
||
FEATURE_PURPOSE_HINTS = (
|
||
"用途",
|
||
"作用",
|
||
"干嘛",
|
||
"做什么",
|
||
"是什么",
|
||
"入口",
|
||
"在哪",
|
||
"页面",
|
||
)
|
||
CORRECTION_CUE_KEYWORDS = (
|
||
"我说的是",
|
||
"我说的不是",
|
||
"不是这个",
|
||
"不是系统架构",
|
||
"不是啊",
|
||
"不对",
|
||
"错了",
|
||
"答错",
|
||
"跑偏",
|
||
"偏了",
|
||
"不是这个意思",
|
||
"你理解错",
|
||
"你答偏了",
|
||
"答非所问",
|
||
"请纠正",
|
||
"纠正一下",
|
||
"请更正",
|
||
"上一条",
|
||
"上一个回答",
|
||
"不要模板",
|
||
"模板化",
|
||
"我要的是",
|
||
"我问的是",
|
||
"不是在问",
|
||
)
|
||
ENGINE_PROCESS_KEYWORDS = (
|
||
"ai引擎",
|
||
"引擎驱动",
|
||
"驱动方式",
|
||
"调用链",
|
||
"调用流程",
|
||
"调用路径",
|
||
"模型路由",
|
||
"模型调用",
|
||
"路由策略",
|
||
"openclaw",
|
||
"网关",
|
||
"ai架构",
|
||
"ai 管家",
|
||
"ai管家",
|
||
"第一层",
|
||
"第二层",
|
||
"第三层",
|
||
"fallback",
|
||
"兜底",
|
||
)
|
||
DEEP_EXPLICIT_KEYWORDS = (
|
||
"深入",
|
||
"深度",
|
||
"详细分析",
|
||
"证据链",
|
||
"调用链",
|
||
"逐层",
|
||
"逐步",
|
||
"详细展开",
|
||
"deep",
|
||
)
|
||
BRIEF_NEGATIVE_FEEDBACK_RE = re.compile(
|
||
r"^(不是啊?|不对|错了|答错了?|跑偏了?|偏了|不是这个(意思)?|不(是|对)啊)$"
|
||
)
|
||
HISTORY_KEYWORDS = ("之前", "上次", "历史", "曾经", "之前说", "last time")
|
||
PMFLOW_KEYWORDS = ("复盘", "charter", "宪章", "task", "任务描述")
|
||
FOLLOWUP_REFERENTIAL_KEYWORDS = (
|
||
"这些",
|
||
"它们",
|
||
"这个",
|
||
"那个",
|
||
"上面",
|
||
"前面",
|
||
"刚才",
|
||
"分别",
|
||
"具体",
|
||
"都有哪些",
|
||
"分别是什么",
|
||
)
|
||
FOLLOWUP_ENUMERATION_KEYWORDS = (
|
||
"分别是什么",
|
||
"分别是啥",
|
||
"都有哪些",
|
||
"具体有哪些",
|
||
"列出来",
|
||
"清单",
|
||
"名单",
|
||
)
|
||
IDENTITY_KEYWORDS = (
|
||
"你是谁",
|
||
"你是",
|
||
"自我介绍",
|
||
"介绍你自己",
|
||
"你能做什么",
|
||
"你会什么",
|
||
"你负责什么",
|
||
"你叫什么",
|
||
)
|
||
ARCHITECTURE_KEYWORDS = (
|
||
"系统架构",
|
||
"架构",
|
||
"运行机制",
|
||
"技术栈",
|
||
"模块",
|
||
"服务组成",
|
||
"docker",
|
||
"数据库",
|
||
"redis",
|
||
"后端",
|
||
"前端",
|
||
)
|
||
CODE_KEYWORDS = (
|
||
"代码",
|
||
"函数",
|
||
"方法",
|
||
"类",
|
||
"模块",
|
||
"文件",
|
||
"api",
|
||
"接口实现",
|
||
"service",
|
||
"model",
|
||
"schema",
|
||
"where defined",
|
||
)
|
||
URL_RE = re.compile(r"https?://\S+", flags=re.IGNORECASE)
|
||
SENTENCE_SPLIT_RE = re.compile(r"(?<=[。!?!?\n])")
|
||
PAGE_MARKER_RE = re.compile(r"\[\[PAGE:(\d{1,6})\]\]")
|
||
|
||
MAX_CONTEXT_TOKENS = 8000
|
||
DEFAULT_TOP_K = 5
|
||
DB_QUERY_TIMEOUT_SECONDS = 5.0
|
||
VECTOR_SEARCH_TIMEOUT_SECONDS = 10.0
|
||
GRAPH_QUERY_TIMEOUT_SECONDS = 4.0
|
||
AGENT_MAX_RETRIEVAL_STEPS = 3
|
||
SESSION_GAP_MINUTES = 30
|
||
SESSION_DEFAULT_TITLE = "新会话"
|
||
SESSION_TITLE_MIN_LEN = 5
|
||
|
||
THINKING_MODE_NORMAL = "normal"
|
||
THINKING_MODE_DEEP = "deep"
|
||
DEEP_RETRIEVAL_STEPS = _env_int("RAG_DEEP_RETRIEVAL_STEPS", 5, minimum=2, maximum=8)
|
||
DEEP_VECTOR_TOP_K = _env_int("RAG_DEEP_VECTOR_TOP_K", 12, minimum=6, maximum=40)
|
||
DEEP_CONTEXT_BLOCKS = _env_int("RAG_DEEP_CONTEXT_BLOCKS", 16, minimum=8, maximum=32)
|
||
DEEP_ANSWER_MAX_TOKENS = _env_int("RAG_DEEP_ANSWER_MAX_TOKENS", 1400, minimum=900, maximum=3000)
|
||
|
||
QUERY_TYPE_COUNT = "count"
|
||
QUERY_TYPE_LIST = "list"
|
||
QUERY_TYPE_STATUS = "status"
|
||
QUERY_TYPE_EXPLAIN = "explain"
|
||
QUERY_TYPE_UNKNOWN = "unknown"
|
||
|
||
ACTION_MODE_AUTO = "auto"
|
||
ACTION_MODE_READ = "read"
|
||
ACTION_MODE_WRITE = "write"
|
||
ACTION_MODE_READ_WRITE = "read_write"
|
||
|
||
REQUIREMENT_WRITE_VERB_KEYWORDS = (
|
||
"新增",
|
||
"增加",
|
||
"添加",
|
||
"接入",
|
||
"支持",
|
||
"实现",
|
||
"创建",
|
||
"新建",
|
||
"记录",
|
||
"入库",
|
||
"同步",
|
||
"补充",
|
||
)
|
||
REQUIREMENT_WRITE_OBJECT_KEYWORDS = (
|
||
"信号",
|
||
"需求",
|
||
"任务",
|
||
"项目",
|
||
"功能",
|
||
"接口",
|
||
"页面",
|
||
"按钮",
|
||
"规则",
|
||
"模块",
|
||
"卡路里",
|
||
"食谱",
|
||
)
|
||
WRITE_EXPRESSIONS = (
|
||
"给",
|
||
"帮我",
|
||
"请帮",
|
||
"请给",
|
||
"麻烦",
|
||
)
|
||
|
||
COUNT_HINTS = ("多少", "几个", "数量", "总数", "count")
|
||
LIST_HINTS = ("哪些", "列表", "清单", "列出", "分别", "名单", "都有哪些")
|
||
STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况")
|
||
|
||
ENTITY_ALIAS_MAP: dict[str, str] = {
|
||
"能力": "skill",
|
||
"技能": "skill",
|
||
"ability": "skill",
|
||
"abilities": "skill",
|
||
"skill": "skill",
|
||
"skills": "skill",
|
||
"负责人": "owner",
|
||
"owner": "owner",
|
||
"assignee": "owner",
|
||
"成员": "member",
|
||
"人员": "member",
|
||
"member": "member",
|
||
"任务": "task",
|
||
"task": "task",
|
||
"tasks": "task",
|
||
"项目": "project",
|
||
"project": "project",
|
||
"projects": "project",
|
||
"信号": "signal",
|
||
"signal": "signal",
|
||
"signals": "signal",
|
||
"函数": "function",
|
||
"function": "function",
|
||
"method": "function",
|
||
"类": "class",
|
||
"class": "class",
|
||
"模块": "module",
|
||
"module": "module",
|
||
"智能配置": "ai_config",
|
||
"ai配置": "ai_config",
|
||
"配置中心": "ai_config",
|
||
"能力管理": "skills",
|
||
"技能管理": "skills",
|
||
"用户管理": "users",
|
||
"项目列表": "projects",
|
||
"团队负载": "workloads",
|
||
"ai管家": "ai_steward",
|
||
}
|
||
TS_SYMBOL_RE = re.compile(
|
||
r"(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)"
|
||
)
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class RetrievedItem:
|
||
source: str
|
||
content: str
|
||
score: float
|
||
source_label: str
|
||
metadata: dict[str, Any]
|
||
|
||
|
||
class AdaptiveRAGService:
|
||
"""项目内统一 RAG 服务。"""
|
||
|
||
_symbol_snapshot_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||
|
||
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
|
||
self.db = db
|
||
self.tenant_id = int(tenant_id)
|
||
self.llm = LLMService()
|
||
self.context_cache = context_cache_service
|
||
|
||
@staticmethod
|
||
def _normalize_thinking_mode(value: Optional[str]) -> str:
|
||
text = str(value or "").strip().lower()
|
||
if text == THINKING_MODE_DEEP:
|
||
return THINKING_MODE_DEEP
|
||
return THINKING_MODE_NORMAL
|
||
|
||
@staticmethod
|
||
def _normalize_conversation_key(value: Optional[str]) -> str:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return ""
|
||
cleaned = re.sub(r"\s+", "", raw.lower())
|
||
cleaned = re.sub(r"[^a-z0-9:_\-\.]", "", cleaned)
|
||
return cleaned[:160]
|
||
|
||
def _build_external_conversation_key(
|
||
self,
|
||
*,
|
||
asked_via: str,
|
||
source_chat_id: Optional[str],
|
||
conversation_key: Optional[str],
|
||
) -> str:
|
||
explicit = self._normalize_conversation_key(conversation_key)
|
||
if explicit:
|
||
return explicit
|
||
channel = str(asked_via or "").strip().lower()
|
||
if channel == "feishu":
|
||
chat = str(source_chat_id or "").strip()
|
||
if chat:
|
||
return self._normalize_conversation_key(f"feishu:{chat}")
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _external_session_id_from_key(conversation_key: str) -> Optional[int]:
|
||
key = str(conversation_key or "").strip()
|
||
if not key:
|
||
return None
|
||
digest = hashlib.sha1(key.encode("utf-8")).hexdigest()
|
||
bucket = int(digest[:10], 16) % 500_000_000
|
||
return 1_500_000_000 + bucket
|
||
|
||
# ------------------------------------------------------------------
|
||
# Intent
|
||
# ------------------------------------------------------------------
|
||
async def classify_intent(self, message: str) -> dict[str, Any]:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return {
|
||
"intent": "statement",
|
||
"confidence": 1.0,
|
||
"question_part": "",
|
||
"statement_part": "",
|
||
}
|
||
|
||
heuristic = self._classify_intent_rule(text)
|
||
if heuristic["confidence"] >= 0.86:
|
||
return heuristic
|
||
|
||
prompt = PROMPT_RAG_INTENT_CLASSIFICATION.format(message=text[:1200]).strip()
|
||
try:
|
||
raw = await self.llm.generate(
|
||
prompt,
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
function_name="rag_intent_classification",
|
||
temperature=0.0,
|
||
max_tokens=220,
|
||
use_prompt_profile=False,
|
||
)
|
||
parsed = self._parse_json_object(raw)
|
||
intent = str(parsed.get("intent") or heuristic["intent"]).strip().lower()
|
||
if intent not in {"question", "statement", "mixed"}:
|
||
intent = heuristic["intent"]
|
||
confidence = self._to_float(parsed.get("confidence"), heuristic["confidence"])
|
||
question_part = str(parsed.get("question_part") or "").strip()
|
||
statement_part = str(parsed.get("statement_part") or "").strip()
|
||
if not question_part and intent in {"question", "mixed"}:
|
||
question_part = heuristic["question_part"]
|
||
if not statement_part and intent in {"statement", "mixed"}:
|
||
statement_part = heuristic["statement_part"]
|
||
return {
|
||
"intent": intent,
|
||
"confidence": max(0.0, min(1.0, confidence)),
|
||
"question_part": question_part,
|
||
"statement_part": statement_part,
|
||
}
|
||
except Exception:
|
||
return heuristic
|
||
|
||
def _classify_intent_rule(self, text: str) -> dict[str, Any]:
|
||
question_score = 0.0
|
||
statement_score = 0.0
|
||
lowered = text.lower()
|
||
|
||
if "?" in text or "?" in text:
|
||
question_score += 0.35
|
||
if lowered.endswith(("吗", "么", "呢", "呀", "啊")):
|
||
question_score += 0.24
|
||
for hint in QUESTION_HINTS:
|
||
if hint in lowered:
|
||
question_score += 0.08
|
||
if PROJECT_EXISTENCE_RE.search(text):
|
||
question_score += 0.26
|
||
if re.search(r"(现在|当前|目前|请问).*(项目|任务|负责人|状态|进度|列表|清单)", text):
|
||
question_score += 0.18
|
||
for hint in STATEMENT_HINTS:
|
||
if hint in lowered:
|
||
statement_score += 0.10
|
||
|
||
sentences = [s.strip() for s in SENTENCE_SPLIT_RE.split(text) if s.strip()]
|
||
question_sentences = [s for s in sentences if "?" in s or "?" in s or any(k in s.lower() for k in QUESTION_HINTS)]
|
||
statement_sentences = [s for s in sentences if s not in question_sentences]
|
||
|
||
if question_sentences and statement_sentences:
|
||
return {
|
||
"intent": "mixed",
|
||
"confidence": 0.88,
|
||
"question_part": " ".join(question_sentences)[:1000],
|
||
"statement_part": " ".join(statement_sentences)[:1000],
|
||
}
|
||
|
||
if question_score >= 0.28 and question_score >= statement_score:
|
||
return {
|
||
"intent": "question",
|
||
"confidence": min(0.9, 0.62 + question_score),
|
||
"question_part": text[:1000],
|
||
"statement_part": "",
|
||
}
|
||
|
||
return {
|
||
"intent": "statement",
|
||
"confidence": min(0.9, 0.66 + statement_score),
|
||
"question_part": "",
|
||
"statement_part": text[:1000],
|
||
}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Public ask flow
|
||
# ------------------------------------------------------------------
|
||
async def answer_question(
|
||
self,
|
||
*,
|
||
question: str,
|
||
asked_by: Optional[int],
|
||
asked_via: str,
|
||
project_id: Optional[int] = None,
|
||
source_chat_id: Optional[str] = None,
|
||
session_id: Optional[int] = None,
|
||
conversation_key: Optional[str] = None,
|
||
force_new_session: bool = False,
|
||
thinking_mode: str = THINKING_MODE_NORMAL,
|
||
action_mode: str = ACTION_MODE_AUTO,
|
||
sync_requirement: bool = True,
|
||
) -> dict[str, Any]:
|
||
started = time.perf_counter()
|
||
clean_q = str(question or "").strip()
|
||
external_conversation_key = self._build_external_conversation_key(
|
||
asked_via=asked_via,
|
||
source_chat_id=source_chat_id,
|
||
conversation_key=conversation_key,
|
||
)
|
||
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
||
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
||
asked_via_lower = str(asked_via or "").strip().lower()
|
||
deep_retrieval_steps = DEEP_RETRIEVAL_STEPS
|
||
deep_vector_top_k = DEEP_VECTOR_TOP_K
|
||
if asked_via_lower == "feishu":
|
||
# 飞书链路优先低时延,保留 deep 能力但收紧预算。
|
||
deep_retrieval_steps = min(DEEP_RETRIEVAL_STEPS, 3)
|
||
deep_vector_top_k = min(DEEP_VECTOR_TOP_K, 8)
|
||
if not clean_q:
|
||
return self._build_response_payload(
|
||
answer="问题为空,无法检索。",
|
||
sources=[],
|
||
domains_used=[],
|
||
response_time_ms=0,
|
||
db_query_ms=0,
|
||
vector_search_ms=0,
|
||
db_timeout=False,
|
||
vector_timeout=False,
|
||
qa_id=None,
|
||
session_id=None,
|
||
turn_index=1,
|
||
relevance_max=0.0,
|
||
query_type=QUERY_TYPE_UNKNOWN,
|
||
confidence=0.0,
|
||
missing_fields=["question"],
|
||
thinking_mode=active_thinking_mode,
|
||
)
|
||
|
||
session_id, turn_index = await self._touch_session_for_new_record(
|
||
asked_by=asked_by,
|
||
asked_via=asked_via,
|
||
project_id=project_id,
|
||
question=clean_q,
|
||
preferred_session_id=session_id,
|
||
force_new_session=bool(force_new_session),
|
||
)
|
||
context_session_id: Optional[int]
|
||
if session_id is not None:
|
||
context_session_id = int(session_id)
|
||
elif asked_by is None:
|
||
context_session_id = self._external_session_id_from_key(external_conversation_key)
|
||
else:
|
||
context_session_id = None
|
||
conversation = await self._load_recent_conversation(
|
||
asked_by=asked_by,
|
||
asked_via=asked_via,
|
||
project_id=project_id,
|
||
session_id=session_id,
|
||
external_session_id=context_session_id,
|
||
limit=10,
|
||
)
|
||
state_row = await self._ensure_session_state(session_id=session_id)
|
||
effective_q, rewrite_meta = self._rewrite_question_with_state(
|
||
question=clean_q,
|
||
conversation=conversation,
|
||
session_state=state_row,
|
||
)
|
||
query_type = self._classify_query_type(effective_q)
|
||
requested_action_mode = self._normalize_action_mode(action_mode)
|
||
effective_action_mode = self._resolve_action_mode(
|
||
requested_mode=requested_action_mode,
|
||
question=clean_q,
|
||
effective_question=effective_q,
|
||
)
|
||
if (
|
||
bool(sync_requirement)
|
||
and asked_via_lower != "feishu"
|
||
and effective_action_mode in {ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE}
|
||
):
|
||
ack_result = await self._handle_ack_first_requirement(
|
||
question=clean_q,
|
||
effective_question=effective_q,
|
||
asked_by=asked_by,
|
||
asked_via=asked_via,
|
||
project_id=project_id,
|
||
session_id=session_id,
|
||
turn_index=turn_index,
|
||
state_row=state_row,
|
||
rewrite_meta=rewrite_meta,
|
||
context_session_id=context_session_id,
|
||
query_type=query_type,
|
||
action_mode=effective_action_mode,
|
||
started_at=started,
|
||
)
|
||
if ack_result is not None:
|
||
return ack_result
|
||
forced_deep_reason = ""
|
||
if not deep_thinking:
|
||
forced_deep_reason = self._forced_deep_reason(
|
||
question=clean_q,
|
||
effective_question=effective_q,
|
||
conversation=conversation,
|
||
)
|
||
if forced_deep_reason and self._should_force_deep_for_channel(
|
||
asked_via=asked_via,
|
||
question=clean_q,
|
||
effective_question=effective_q,
|
||
reason=forced_deep_reason,
|
||
):
|
||
deep_thinking = True
|
||
active_thinking_mode = THINKING_MODE_DEEP
|
||
else:
|
||
forced_deep_reason = ""
|
||
|
||
clarification_answer = self._build_clarification_question(
|
||
original_question=clean_q,
|
||
effective_question=effective_q,
|
||
session_state=state_row,
|
||
)
|
||
if clarification_answer:
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
clarify_sources = [
|
||
{
|
||
"source": "system",
|
||
"label": "Session State",
|
||
"score": 0.95,
|
||
"metadata": {"mode": "clarification", "reason": "missing_subject"},
|
||
}
|
||
]
|
||
qa = QARecord(
|
||
tenant_id=self.tenant_id,
|
||
question=clean_q,
|
||
answer=clarification_answer,
|
||
sources=clarify_sources,
|
||
intent="question",
|
||
domains_used=["system_context"],
|
||
response_time_ms=elapsed,
|
||
asked_by=asked_by,
|
||
asked_via=(asked_via or "pmflow")[:20],
|
||
project_id=project_id,
|
||
session_id=session_id,
|
||
turn_index=turn_index,
|
||
)
|
||
self.db.add(qa)
|
||
await self._update_session_state_after_turn(
|
||
session_state=state_row,
|
||
question=clean_q,
|
||
effective_question=effective_q,
|
||
answer=clarification_answer,
|
||
query_type=query_type,
|
||
ambiguity="needs_clarification",
|
||
rewrite_meta=rewrite_meta,
|
||
sources=clarify_sources,
|
||
)
|
||
await self.db.commit()
|
||
await self.db.refresh(qa)
|
||
await self.context_cache.append_session_messages(
|
||
tenant_id=self.tenant_id,
|
||
session_id=context_session_id,
|
||
question=clean_q,
|
||
answer=clarification_answer,
|
||
)
|
||
return self._build_response_payload(
|
||
answer=clarification_answer,
|
||
sources=clarify_sources,
|
||
domains_used=["system_context"],
|
||
response_time_ms=elapsed,
|
||
db_query_ms=0,
|
||
vector_search_ms=0,
|
||
db_timeout=False,
|
||
vector_timeout=False,
|
||
qa_id=int(qa.id),
|
||
session_id=int(session_id) if session_id is not None else None,
|
||
turn_index=int(turn_index),
|
||
relevance_max=0.95,
|
||
query_type=query_type,
|
||
confidence=0.92,
|
||
missing_fields=["subject"],
|
||
thinking_mode=active_thinking_mode,
|
||
)
|
||
|
||
system_snapshot = await self.context_cache.get_system_snapshot(
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
)
|
||
user_snapshot = await self.context_cache.get_user_snapshot(
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
user_id=asked_by,
|
||
)
|
||
|
||
if not deep_thinking:
|
||
deterministic_result = await self._run_deterministic_query(
|
||
question=effective_q,
|
||
project_id=project_id,
|
||
query_type=query_type,
|
||
)
|
||
if deterministic_result is not None:
|
||
return await self._persist_fast_answer(
|
||
question=clean_q,
|
||
asked_by=asked_by,
|
||
asked_via=asked_via,
|
||
project_id=project_id,
|
||
fast_result=deterministic_result,
|
||
session_id=session_id,
|
||
external_session_id=context_session_id,
|
||
turn_index=turn_index,
|
||
query_type=str(deterministic_result.get("query_type") or query_type),
|
||
missing_fields=list(deterministic_result.get("missing_fields") or []),
|
||
state_row=state_row,
|
||
effective_question=effective_q,
|
||
rewrite_meta=rewrite_meta,
|
||
thinking_mode=active_thinking_mode,
|
||
)
|
||
|
||
direct_answer = None
|
||
if not deep_thinking:
|
||
direct_answer = self._build_snapshot_direct_answer(
|
||
effective_q,
|
||
system_snapshot=system_snapshot,
|
||
user_snapshot=user_snapshot,
|
||
)
|
||
if direct_answer:
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
direct_sources = [
|
||
{
|
||
"source": "system",
|
||
"label": "System Snapshot",
|
||
"score": 0.99,
|
||
"metadata": {"mode": "snapshot_direct", "topic": "identity_or_architecture"},
|
||
}
|
||
]
|
||
if user_snapshot:
|
||
direct_sources.append(
|
||
{
|
||
"source": "system",
|
||
"label": "User Snapshot",
|
||
"score": 0.98,
|
||
"metadata": {"mode": "snapshot_direct", "topic": "current_user"},
|
||
}
|
||
)
|
||
|
||
qa = QARecord(
|
||
tenant_id=self.tenant_id,
|
||
question=clean_q,
|
||
answer=direct_answer,
|
||
sources=direct_sources,
|
||
intent="question",
|
||
domains_used=["system_context"],
|
||
response_time_ms=elapsed,
|
||
asked_by=asked_by,
|
||
asked_via=(asked_via or "pmflow")[:20],
|
||
project_id=project_id,
|
||
session_id=session_id,
|
||
turn_index=turn_index,
|
||
)
|
||
self.db.add(qa)
|
||
await self._update_session_state_after_turn(
|
||
session_state=state_row,
|
||
question=clean_q,
|
||
effective_question=effective_q,
|
||
answer=direct_answer,
|
||
query_type=QUERY_TYPE_EXPLAIN,
|
||
ambiguity="clear",
|
||
rewrite_meta=rewrite_meta,
|
||
sources=direct_sources,
|
||
)
|
||
await self.db.commit()
|
||
await self.db.refresh(qa)
|
||
await self.context_cache.append_session_messages(
|
||
tenant_id=self.tenant_id,
|
||
session_id=context_session_id,
|
||
question=clean_q,
|
||
answer=direct_answer,
|
||
)
|
||
return self._build_response_payload(
|
||
answer=direct_answer,
|
||
sources=direct_sources,
|
||
domains_used=["system_context"],
|
||
response_time_ms=elapsed,
|
||
db_query_ms=0,
|
||
vector_search_ms=0,
|
||
db_timeout=False,
|
||
vector_timeout=False,
|
||
qa_id=int(qa.id),
|
||
session_id=int(session_id) if session_id is not None else None,
|
||
turn_index=int(turn_index),
|
||
relevance_max=1.0,
|
||
query_type=QUERY_TYPE_EXPLAIN,
|
||
confidence=0.99,
|
||
missing_fields=[],
|
||
thinking_mode=active_thinking_mode,
|
||
)
|
||
|
||
is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q)
|
||
if not deep_thinking and not is_snapshot_direct_question:
|
||
glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id)
|
||
if glm_semantic_result is not None:
|
||
return await self._persist_fast_answer(
|
||
question=clean_q,
|
||
asked_by=asked_by,
|
||
asked_via=asked_via,
|
||
project_id=project_id,
|
||
fast_result=glm_semantic_result,
|
||
session_id=session_id,
|
||
external_session_id=context_session_id,
|
||
turn_index=turn_index,
|
||
query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN,
|
||
missing_fields=[],
|
||
state_row=state_row,
|
||
effective_question=effective_q,
|
||
rewrite_meta=rewrite_meta,
|
||
thinking_mode=active_thinking_mode,
|
||
)
|
||
|
||
domains = self._route_domains(effective_q)
|
||
if str(asked_via or "").strip().lower() == "feishu" and source_chat_id and "feishu" not in domains:
|
||
domains.append("feishu")
|
||
retrieved, search_meta = await self._multi_step_search(
|
||
question=effective_q,
|
||
domains=domains,
|
||
project_id=project_id,
|
||
source_chat_id=source_chat_id,
|
||
session_state=state_row,
|
||
max_steps_override=deep_retrieval_steps if deep_thinking else None,
|
||
vector_top_k=deep_vector_top_k if deep_thinking else None,
|
||
)
|
||
db_query_ms = int(search_meta.get("db_query_ms") or 0)
|
||
vector_search_ms = int(search_meta.get("vector_search_ms") or 0)
|
||
graph_query_ms = int(search_meta.get("graph_query_ms") or 0)
|
||
db_timeout = bool(search_meta.get("db_timeout"))
|
||
vector_timeout = bool(search_meta.get("vector_timeout"))
|
||
graph_timeout = bool(search_meta.get("graph_timeout"))
|
||
tool_trace = list(search_meta.get("tool_trace") or [])
|
||
tool_trace.insert(
|
||
0,
|
||
{
|
||
"step": 0,
|
||
"tool": "thinking_mode",
|
||
"mode": active_thinking_mode,
|
||
"fast_path_bypassed": bool(deep_thinking),
|
||
"reason": forced_deep_reason or ("manual_deep" if self._normalize_thinking_mode(thinking_mode) == THINKING_MODE_DEEP else "normal"),
|
||
},
|
||
)
|
||
final_domains = [str(x) for x in (search_meta.get("domains_used") or domains)]
|
||
|
||
max_score = retrieved[0].score if retrieved else 0.0
|
||
answer_text = await self._generate_answer(
|
||
effective_q,
|
||
retrieved,
|
||
conversation,
|
||
project_id,
|
||
system_snapshot=system_snapshot,
|
||
user_snapshot=user_snapshot,
|
||
relevance_max=max_score,
|
||
thinking_mode=active_thinking_mode,
|
||
context_block_limit=DEEP_CONTEXT_BLOCKS if deep_thinking else None,
|
||
answer_max_tokens=DEEP_ANSWER_MAX_TOKENS if deep_thinking else None,
|
||
)
|
||
used_sources = self._dedupe_sources(retrieved)
|
||
if not used_sources:
|
||
used_sources = [
|
||
{
|
||
"source": "system",
|
||
"label": "System Snapshot",
|
||
"score": round(max(max_score, 0.6), 4),
|
||
"metadata": {"mode": "snapshot_only", "relevance_max": round(max_score, 4)},
|
||
}
|
||
]
|
||
if user_snapshot:
|
||
used_sources.append(
|
||
{
|
||
"source": "system",
|
||
"label": "User Snapshot",
|
||
"score": round(max(max_score, 0.58), 4),
|
||
"metadata": {"mode": "snapshot_only", "relevance_max": round(max_score, 4)},
|
||
}
|
||
)
|
||
|
||
auto_retry_reason = ""
|
||
if not deep_thinking:
|
||
auto_retry_reason = self._should_auto_deep_retry(
|
||
question=clean_q,
|
||
effective_question=effective_q,
|
||
sources=used_sources,
|
||
relevance_max=max_score,
|
||
)
|
||
if auto_retry_reason and not self._allow_auto_deep_retry_for_channel(
|
||
asked_via=asked_via,
|
||
question=clean_q,
|
||
effective_question=effective_q,
|
||
reason=auto_retry_reason,
|
||
):
|
||
auto_retry_reason = ""
|
||
if auto_retry_reason:
|
||
deep_retrieved, deep_meta = await self._multi_step_search(
|
||
question=effective_q,
|
||
domains=domains,
|
||
project_id=project_id,
|
||
source_chat_id=source_chat_id,
|
||
session_state=state_row,
|
||
max_steps_override=deep_retrieval_steps,
|
||
vector_top_k=deep_vector_top_k,
|
||
)
|
||
deep_max_score = deep_retrieved[0].score if deep_retrieved else 0.0
|
||
deep_answer_text = await self._generate_answer(
|
||
effective_q,
|
||
deep_retrieved,
|
||
conversation,
|
||
project_id,
|
||
system_snapshot=system_snapshot,
|
||
user_snapshot=user_snapshot,
|
||
relevance_max=deep_max_score,
|
||
thinking_mode=THINKING_MODE_DEEP,
|
||
context_block_limit=DEEP_CONTEXT_BLOCKS,
|
||
answer_max_tokens=DEEP_ANSWER_MAX_TOKENS,
|
||
)
|
||
deep_sources = self._dedupe_sources(deep_retrieved)
|
||
if not deep_sources:
|
||
deep_sources = [
|
||
{
|
||
"source": "system",
|
||
"label": "System Snapshot",
|
||
"score": round(max(deep_max_score, 0.6), 4),
|
||
"metadata": {"mode": "snapshot_only", "relevance_max": round(deep_max_score, 4)},
|
||
}
|
||
]
|
||
if user_snapshot:
|
||
deep_sources.append(
|
||
{
|
||
"source": "system",
|
||
"label": "User Snapshot",
|
||
"score": round(max(deep_max_score, 0.58), 4),
|
||
"metadata": {"mode": "snapshot_only", "relevance_max": round(deep_max_score, 4)},
|
||
}
|
||
)
|
||
|
||
merged_metrics = self._merge_search_metrics(
|
||
{
|
||
"db_query_ms": db_query_ms,
|
||
"vector_search_ms": vector_search_ms,
|
||
"graph_query_ms": graph_query_ms,
|
||
"db_timeout": db_timeout,
|
||
"vector_timeout": vector_timeout,
|
||
"graph_timeout": graph_timeout,
|
||
},
|
||
deep_meta,
|
||
)
|
||
db_query_ms = int(merged_metrics.get("db_query_ms") or 0)
|
||
vector_search_ms = int(merged_metrics.get("vector_search_ms") or 0)
|
||
graph_query_ms = int(merged_metrics.get("graph_query_ms") or 0)
|
||
db_timeout = bool(merged_metrics.get("db_timeout"))
|
||
vector_timeout = bool(merged_metrics.get("vector_timeout"))
|
||
graph_timeout = bool(merged_metrics.get("graph_timeout"))
|
||
|
||
deep_trace = list(deep_meta.get("tool_trace") or [])
|
||
tool_trace.append(
|
||
{
|
||
"step": 98,
|
||
"tool": "auto_deep_retry",
|
||
"reason": auto_retry_reason,
|
||
"prev_relevance_max": round(float(max_score or 0.0), 4),
|
||
"prev_snapshot_only": self._is_snapshot_only_sources(used_sources),
|
||
}
|
||
)
|
||
for row in deep_trace:
|
||
tool_trace.append(row)
|
||
|
||
final_domains = [str(x) for x in (deep_meta.get("domains_used") or final_domains or domains)]
|
||
retrieved = deep_retrieved
|
||
max_score = deep_max_score
|
||
answer_text = deep_answer_text
|
||
used_sources = deep_sources
|
||
deep_thinking = True
|
||
active_thinking_mode = THINKING_MODE_DEEP
|
||
|
||
evidence_guard_triggered = self._should_block_answer_with_guard(
|
||
question=clean_q,
|
||
effective_question=effective_q,
|
||
sources=used_sources,
|
||
relevance_max=max_score,
|
||
)
|
||
if evidence_guard_triggered:
|
||
answer_text = self._build_evidence_guard_answer(clean_q)
|
||
used_sources = [
|
||
{
|
||
"source": "system",
|
||
"label": "Evidence Guard",
|
||
"score": 0.99,
|
||
"metadata": {"mode": "guard", "reason": "snapshot_only_process_query"},
|
||
}
|
||
]
|
||
max_score = min(float(max_score or 0.0), 0.35)
|
||
if "system_context" not in final_domains:
|
||
final_domains.append("system_context")
|
||
tool_trace.append(
|
||
{
|
||
"step": 99,
|
||
"tool": "answer_guard",
|
||
"reason": "insufficient_call_chain_evidence",
|
||
}
|
||
)
|
||
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
qa = QARecord(
|
||
tenant_id=self.tenant_id,
|
||
question=clean_q,
|
||
answer=answer_text,
|
||
sources=used_sources,
|
||
intent="question",
|
||
domains_used=final_domains,
|
||
response_time_ms=elapsed,
|
||
asked_by=asked_by,
|
||
asked_via=(asked_via or "pmflow")[:20],
|
||
project_id=project_id,
|
||
session_id=session_id,
|
||
turn_index=turn_index,
|
||
)
|
||
self.db.add(qa)
|
||
confidence = round(max(0.2, min(1.0, max_score if max_score > 0 else 0.55)), 4)
|
||
if evidence_guard_triggered:
|
||
confidence = min(confidence, 0.35)
|
||
missing_fields = []
|
||
if max_score < 0.45:
|
||
missing_fields = ["high_relevance_evidence"]
|
||
if evidence_guard_triggered:
|
||
missing_fields = sorted(set([*missing_fields, "call_chain_evidence", "target_scope"]))
|
||
await self._update_session_state_after_turn(
|
||
session_state=state_row,
|
||
question=clean_q,
|
||
effective_question=effective_q,
|
||
answer=answer_text,
|
||
query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN,
|
||
ambiguity="clear" if not missing_fields else "low_evidence",
|
||
rewrite_meta=rewrite_meta,
|
||
sources=used_sources,
|
||
)
|
||
await self.db.commit()
|
||
await self.db.refresh(qa)
|
||
await self.context_cache.append_session_messages(
|
||
tenant_id=self.tenant_id,
|
||
session_id=context_session_id,
|
||
question=clean_q,
|
||
answer=answer_text,
|
||
)
|
||
|
||
return self._build_response_payload(
|
||
answer=answer_text,
|
||
sources=used_sources,
|
||
domains_used=final_domains,
|
||
response_time_ms=elapsed,
|
||
db_query_ms=db_query_ms,
|
||
vector_search_ms=vector_search_ms,
|
||
graph_query_ms=graph_query_ms,
|
||
db_timeout=db_timeout,
|
||
vector_timeout=vector_timeout,
|
||
graph_timeout=graph_timeout,
|
||
tool_trace=tool_trace,
|
||
qa_id=int(qa.id),
|
||
session_id=int(session_id) if session_id is not None else None,
|
||
turn_index=int(turn_index),
|
||
relevance_max=round(max_score, 4),
|
||
query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN,
|
||
confidence=confidence,
|
||
missing_fields=missing_fields,
|
||
thinking_mode=active_thinking_mode,
|
||
)
|
||
|
||
def _build_response_payload(
|
||
self,
|
||
*,
|
||
answer: str,
|
||
sources: list[dict[str, Any]],
|
||
domains_used: list[str],
|
||
response_time_ms: int,
|
||
db_query_ms: int,
|
||
vector_search_ms: int,
|
||
graph_query_ms: int = 0,
|
||
db_timeout: bool = False,
|
||
vector_timeout: bool = False,
|
||
graph_timeout: bool = False,
|
||
qa_id: Optional[int] = None,
|
||
session_id: Optional[int] = None,
|
||
turn_index: int = 1,
|
||
relevance_max: float = 0.0,
|
||
query_type: str = QUERY_TYPE_UNKNOWN,
|
||
confidence: float = 0.0,
|
||
missing_fields: Optional[list[str]] = None,
|
||
tool_trace: Optional[list[dict[str, Any]]] = None,
|
||
thinking_mode: str = THINKING_MODE_NORMAL,
|
||
action_taken: Optional[str] = None,
|
||
signal_id: Optional[int] = None,
|
||
reply_required: bool = False,
|
||
reply_text: Optional[str] = None,
|
||
ack_latency_ms: Optional[int] = None,
|
||
trace_id: Optional[str] = None,
|
||
processing_stage: Optional[str] = None,
|
||
) -> dict[str, Any]:
|
||
evidence: list[dict[str, Any]] = []
|
||
for item in sources[:8]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
evidence.append(
|
||
{
|
||
"source": str(item.get("source") or "unknown"),
|
||
"label": str(item.get("label") or "unknown"),
|
||
"score": round(self._to_float(item.get("score"), 0.0), 4),
|
||
"metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {},
|
||
}
|
||
)
|
||
return {
|
||
"answer": str(answer or "").strip(),
|
||
"evidence": evidence,
|
||
"confidence": round(max(0.0, min(1.0, self._to_float(confidence, 0.0))), 4),
|
||
"thinking_mode": self._normalize_thinking_mode(thinking_mode),
|
||
"missing_fields": [str(x) for x in (missing_fields or []) if str(x).strip()],
|
||
"query_type": str(query_type or QUERY_TYPE_UNKNOWN),
|
||
"sources": sources,
|
||
"domains_used": domains_used,
|
||
"response_time_ms": int(response_time_ms or 0),
|
||
"retrieval_metrics": {
|
||
"db_query_ms": int(db_query_ms or 0),
|
||
"vector_search_ms": int(vector_search_ms or 0),
|
||
"graph_query_ms": int(graph_query_ms or 0),
|
||
"db_timeout": bool(db_timeout),
|
||
"vector_timeout": bool(vector_timeout),
|
||
"graph_timeout": bool(graph_timeout),
|
||
},
|
||
"tool_trace": tool_trace if isinstance(tool_trace, list) else [],
|
||
"qa_id": int(qa_id) if qa_id is not None else None,
|
||
"session_id": int(session_id) if session_id is not None else None,
|
||
"turn_index": int(turn_index or 1),
|
||
"relevance_max": round(max(0.0, self._to_float(relevance_max, 0.0)), 4),
|
||
"action_taken": str(action_taken).strip() if action_taken else None,
|
||
"signal_id": int(signal_id) if signal_id is not None else None,
|
||
"reply_required": bool(reply_required),
|
||
"reply_text": str(reply_text or "").strip() if reply_text else None,
|
||
"ack_latency_ms": int(ack_latency_ms) if ack_latency_ms is not None else None,
|
||
"trace_id": str(trace_id or "").strip() or None,
|
||
"processing_stage": str(processing_stage or "").strip() or None,
|
||
}
|
||
|
||
@staticmethod
|
||
def _extract_ack_metadata_from_sources(
|
||
sources: Any,
|
||
*,
|
||
answer: str = "",
|
||
) -> dict[str, Any]:
|
||
rows = sources if isinstance(sources, list) else []
|
||
action_taken = ""
|
||
signal_id: Optional[int] = None
|
||
reply_required = False
|
||
ack_latency_ms: Optional[int] = None
|
||
trace_id = ""
|
||
processing_stage = ""
|
||
for source in rows:
|
||
if not isinstance(source, dict):
|
||
continue
|
||
meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {}
|
||
candidate_action = str(meta.get("action_taken") or "").strip()
|
||
if not candidate_action:
|
||
continue
|
||
action_taken = candidate_action
|
||
try:
|
||
signal_id = int(meta.get("signal_id")) if meta.get("signal_id") is not None else None
|
||
except Exception:
|
||
signal_id = None
|
||
trace_id = str(meta.get("trace_id") or "").strip()
|
||
processing_stage = str(meta.get("processing_stage") or meta.get("action_mode") or "").strip()
|
||
try:
|
||
ack_latency_ms = (
|
||
int(meta.get("ack_latency_ms")) if meta.get("ack_latency_ms") is not None else None
|
||
)
|
||
except Exception:
|
||
ack_latency_ms = None
|
||
reply_required = True
|
||
break
|
||
reply_text = str(answer or "").strip() if reply_required else ""
|
||
normalized_stage = processing_stage
|
||
if normalized_stage == "write":
|
||
normalized_stage = "ack_only"
|
||
return {
|
||
"action_taken": action_taken or None,
|
||
"signal_id": signal_id,
|
||
"reply_required": bool(reply_required),
|
||
"reply_text": reply_text or None,
|
||
"ack_latency_ms": ack_latency_ms,
|
||
"trace_id": trace_id or None,
|
||
"processing_stage": normalized_stage or None,
|
||
}
|
||
|
||
@staticmethod
|
||
def _normalize_action_mode(value: Optional[str]) -> str:
|
||
text = str(value or "").strip().lower()
|
||
if text in {ACTION_MODE_READ, ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE}:
|
||
return text
|
||
return ACTION_MODE_AUTO
|
||
|
||
@classmethod
|
||
def _looks_like_write_intent(cls, question: str) -> bool:
|
||
text = str(question or "").strip().lower()
|
||
if not text:
|
||
return False
|
||
has_verb = any(k in text for k in REQUIREMENT_WRITE_VERB_KEYWORDS)
|
||
has_object = any(k in text for k in REQUIREMENT_WRITE_OBJECT_KEYWORDS)
|
||
if not has_verb or not has_object:
|
||
return False
|
||
if any(prefix in text for prefix in WRITE_EXPRESSIONS):
|
||
return True
|
||
if "新增" in text or "增加" in text or "记录" in text or "入库" in text:
|
||
return True
|
||
return False
|
||
|
||
@classmethod
|
||
def _resolve_action_mode(
|
||
cls,
|
||
*,
|
||
requested_mode: str,
|
||
question: str,
|
||
effective_question: str,
|
||
) -> str:
|
||
mode = cls._normalize_action_mode(requested_mode)
|
||
if mode != ACTION_MODE_AUTO:
|
||
return mode
|
||
|
||
merged = f"{question} {effective_question}".strip()
|
||
if not cls._looks_like_write_intent(merged):
|
||
return ACTION_MODE_READ
|
||
has_question_form = ("?" in merged) or ("?" in merged) or cls._contains_any(merged, QUESTION_HINTS)
|
||
if has_question_form:
|
||
return ACTION_MODE_READ_WRITE
|
||
return ACTION_MODE_WRITE
|
||
|
||
@staticmethod
|
||
def _normalize_requirement_summary(text: str) -> str:
|
||
normalized = re.sub(r"\s+", " ", str(text or "").strip())
|
||
normalized = normalized.strip(",,。.!!??::;;")
|
||
return normalized[:300]
|
||
|
||
@staticmethod
|
||
def _build_ack_trace_id(question: str) -> str:
|
||
base = str(question or "").strip()
|
||
digest = hashlib.sha1(base.encode("utf-8")).hexdigest()[:10] if base else "empty"
|
||
return f"rag-ack-{int(time.time() * 1000)}-{digest}"
|
||
|
||
async def _ensure_project_signal_link(
|
||
self,
|
||
*,
|
||
signal_id: int,
|
||
project_id: int,
|
||
link_type: str = "related",
|
||
) -> None:
|
||
existing = (
|
||
await self.db.execute(
|
||
select(ProjectSignalLink.id).where(
|
||
and_(
|
||
ProjectSignalLink.tenant_id == self.tenant_id,
|
||
ProjectSignalLink.project_id == int(project_id),
|
||
ProjectSignalLink.signal_id == int(signal_id),
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if existing is not None:
|
||
return
|
||
self.db.add(
|
||
ProjectSignalLink(
|
||
tenant_id=self.tenant_id,
|
||
project_id=int(project_id),
|
||
signal_id=int(signal_id),
|
||
link_type=link_type,
|
||
relevance_score=1.0,
|
||
)
|
||
)
|
||
|
||
async def _create_or_reuse_requirement_signal(
|
||
self,
|
||
*,
|
||
question: str,
|
||
effective_question: str,
|
||
asked_by: Optional[int],
|
||
project_id: Optional[int],
|
||
) -> tuple[str, Optional[int], Optional[int], str]:
|
||
summary = self._normalize_requirement_summary(effective_question or question)
|
||
resolved_project_id: Optional[int] = project_id
|
||
if resolved_project_id is None:
|
||
scoped_id, _, _ = await self._resolve_project_scope_for_question(
|
||
question=effective_question or question,
|
||
project_id=project_id,
|
||
)
|
||
resolved_project_id = scoped_id
|
||
|
||
existing_stmt = select(Signal).where(
|
||
and_(
|
||
Signal.tenant_id == self.tenant_id,
|
||
Signal.source == "PMFlow",
|
||
Signal.summary == summary,
|
||
)
|
||
)
|
||
if resolved_project_id is None:
|
||
existing_stmt = existing_stmt.where(Signal.matched_project_id.is_(None))
|
||
else:
|
||
existing_stmt = existing_stmt.where(Signal.matched_project_id == int(resolved_project_id))
|
||
existing = (
|
||
await self.db.execute(
|
||
existing_stmt.order_by(Signal.created_at.desc(), Signal.id.desc()).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if existing is not None:
|
||
if resolved_project_id is not None:
|
||
try:
|
||
await self._ensure_project_signal_link(
|
||
signal_id=int(existing.id),
|
||
project_id=int(resolved_project_id),
|
||
)
|
||
await self.db.commit()
|
||
except Exception as exc:
|
||
await self.db.rollback()
|
||
return ("signal_failed", None, resolved_project_id, str(exc)[:240])
|
||
return ("signal_deduped", int(existing.id), resolved_project_id, "")
|
||
|
||
actor = f"PMFlow用户#{int(asked_by)}" if asked_by is not None else "PMFlow用户"
|
||
content = f"[{actor}]: {summary}"
|
||
strategic_payload = {
|
||
"source": "pmflow_qa",
|
||
"original_question": str(question or "")[:4000],
|
||
"normalized_question": summary,
|
||
"write_mode": "ack_first",
|
||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
signal = Signal(
|
||
source="PMFlow",
|
||
content=content[:2000],
|
||
impact_score=0.65,
|
||
priority="medium",
|
||
processed=False,
|
||
strategic_analysis=json.dumps(strategic_payload, ensure_ascii=False),
|
||
tasks_generated=json.dumps([], ensure_ascii=False),
|
||
matched_project_id=int(resolved_project_id) if resolved_project_id is not None else None,
|
||
title=summary[:120] or "PMFlow 新需求信号",
|
||
signal_type="新需求",
|
||
summary=summary,
|
||
reasoning=json.dumps(["来自 PMFlow 问答写入意图,按 ACK-first 策略快速入库。"], ensure_ascii=False),
|
||
confidence=0.74,
|
||
recommended_action="enter_only",
|
||
status="待处理",
|
||
tenant_id=self.tenant_id,
|
||
)
|
||
try:
|
||
self.db.add(signal)
|
||
await self.db.flush()
|
||
if resolved_project_id is not None:
|
||
await self._ensure_project_signal_link(
|
||
signal_id=int(signal.id),
|
||
project_id=int(resolved_project_id),
|
||
)
|
||
await self.db.commit()
|
||
await self.db.refresh(signal)
|
||
return ("signal_created", int(signal.id), resolved_project_id, "")
|
||
except Exception as exc:
|
||
await self.db.rollback()
|
||
return ("signal_failed", None, resolved_project_id, str(exc)[:240])
|
||
|
||
@staticmethod
|
||
def _build_requirement_ack_text(
|
||
*,
|
||
action_taken: str,
|
||
signal_id: Optional[int],
|
||
project_id: Optional[int],
|
||
error_reason: str,
|
||
) -> str:
|
||
project_text = f"项目#{int(project_id)}" if project_id is not None else "未指定项目"
|
||
if action_taken == "signal_created" and signal_id is not None:
|
||
return f"已入库:已为{project_text}创建信号 #{int(signal_id)},并进入待处理队列。"
|
||
if action_taken == "signal_deduped" and signal_id is not None:
|
||
return f"已入库:检测到重复需求,已复用{project_text}的信号 #{int(signal_id)}。"
|
||
if action_taken == "signal_failed":
|
||
reason = error_reason or "写入异常"
|
||
return f"未完成入库:{reason}。我已记录本次失败,请稍后重试。"
|
||
return "已收到需求,正在处理中。"
|
||
|
||
async def _handle_ack_first_requirement(
|
||
self,
|
||
*,
|
||
question: str,
|
||
effective_question: str,
|
||
asked_by: Optional[int],
|
||
asked_via: str,
|
||
project_id: Optional[int],
|
||
session_id: Optional[int],
|
||
turn_index: int,
|
||
state_row: Optional[QASessionState],
|
||
rewrite_meta: dict[str, Any],
|
||
context_session_id: Optional[int],
|
||
query_type: str,
|
||
action_mode: str,
|
||
started_at: float,
|
||
) -> Optional[dict[str, Any]]:
|
||
action_taken, linked_signal_id, resolved_project_id, error_reason = await self._create_or_reuse_requirement_signal(
|
||
question=question,
|
||
effective_question=effective_question,
|
||
asked_by=asked_by,
|
||
project_id=project_id,
|
||
)
|
||
if not action_taken:
|
||
return None
|
||
|
||
ack_latency_ms = int((time.perf_counter() - started_at) * 1000)
|
||
trace_id = self._build_ack_trace_id(question)
|
||
reply_text = self._build_requirement_ack_text(
|
||
action_taken=action_taken,
|
||
signal_id=linked_signal_id,
|
||
project_id=resolved_project_id,
|
||
error_reason=error_reason,
|
||
)
|
||
source_orchestration_result: Optional[dict[str, Any]] = None
|
||
if (
|
||
linked_signal_id is not None
|
||
and resolved_project_id is not None
|
||
and action_taken in {"signal_created", "signal_deduped"}
|
||
and SignalSourceOrchestrationService.looks_like_signal_source_request(effective_question or question)
|
||
):
|
||
try:
|
||
source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal(
|
||
tenant_id=self.tenant_id,
|
||
project_id=int(resolved_project_id),
|
||
signal_id=int(linked_signal_id),
|
||
initiating_text=effective_question or question,
|
||
requested_via="pmflow",
|
||
operator_user_id=asked_by,
|
||
dry_run=False,
|
||
)
|
||
except Exception as exc:
|
||
source_orchestration_result = {
|
||
"status": "failed",
|
||
"reason": str(exc),
|
||
}
|
||
orchestration_status = str(source_orchestration_result.get("status") or "").lower()
|
||
if orchestration_status in {"completed", "partial", "planned", "pending_manual"}:
|
||
source_payload = source_orchestration_result.get("signal_source") if isinstance(source_orchestration_result.get("signal_source"), dict) else {}
|
||
worker_executions = (
|
||
source_orchestration_result.get("worker_executions")
|
||
if isinstance(source_orchestration_result.get("worker_executions"), list)
|
||
else []
|
||
)
|
||
if orchestration_status == "pending_manual":
|
||
reply_text = (
|
||
f"{reply_text}"
|
||
f" 已记录信号源“{str(source_payload.get('source_name') or '未命名信号源')}”的接入请求,"
|
||
"但当前仍缺少可直接执行的接入配置,已转待确认。"
|
||
)
|
||
else:
|
||
reply_text = (
|
||
f"{reply_text}"
|
||
f" 已登记信号源“{str(source_payload.get('source_name') or '未命名信号源')}”,"
|
||
f"并已下发 {len(worker_executions)} 个 AI worker 执行。"
|
||
)
|
||
answer_text = reply_text
|
||
sources = [
|
||
{
|
||
"source": "pmflow",
|
||
"label": "Signal Queue",
|
||
"score": 0.98 if action_taken != "signal_failed" else 0.42,
|
||
"metadata": {
|
||
"action_taken": action_taken,
|
||
"signal_id": linked_signal_id,
|
||
"project_id": resolved_project_id,
|
||
"action_mode": action_mode,
|
||
"processing_stage": "ack_only",
|
||
"ack_latency_ms": ack_latency_ms,
|
||
"trace_id": trace_id,
|
||
"source_orchestration": source_orchestration_result,
|
||
},
|
||
}
|
||
]
|
||
|
||
qa_id: Optional[int] = None
|
||
try:
|
||
qa = QARecord(
|
||
tenant_id=self.tenant_id,
|
||
question=question,
|
||
answer=answer_text,
|
||
sources=sources,
|
||
intent="statement",
|
||
domains_used=["signal_write"],
|
||
response_time_ms=ack_latency_ms,
|
||
asked_by=asked_by,
|
||
asked_via=(asked_via or "pmflow")[:20],
|
||
project_id=resolved_project_id if resolved_project_id is not None else project_id,
|
||
session_id=session_id,
|
||
turn_index=int(turn_index or 1),
|
||
)
|
||
self.db.add(qa)
|
||
await self._update_session_state_after_turn(
|
||
session_state=state_row,
|
||
question=question,
|
||
effective_question=effective_question,
|
||
answer=answer_text,
|
||
query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_STATUS,
|
||
ambiguity="clear" if action_taken != "signal_failed" else "low_evidence",
|
||
rewrite_meta=rewrite_meta,
|
||
sources=sources,
|
||
)
|
||
await self.db.commit()
|
||
await self.db.refresh(qa)
|
||
qa_id = int(qa.id)
|
||
except Exception:
|
||
await self.db.rollback()
|
||
|
||
try:
|
||
await self.context_cache.append_session_messages(
|
||
tenant_id=self.tenant_id,
|
||
session_id=context_session_id,
|
||
question=question,
|
||
answer=answer_text,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
confidence = 0.94 if action_taken in {"signal_created", "signal_deduped"} else 0.35
|
||
missing_fields = [] if resolved_project_id is not None else ["project_id"]
|
||
return self._build_response_payload(
|
||
answer=answer_text,
|
||
sources=sources,
|
||
domains_used=["signal_write"],
|
||
response_time_ms=ack_latency_ms,
|
||
db_query_ms=0,
|
||
vector_search_ms=0,
|
||
db_timeout=False,
|
||
vector_timeout=False,
|
||
qa_id=qa_id,
|
||
session_id=int(session_id) if session_id is not None else None,
|
||
turn_index=int(turn_index or 1),
|
||
relevance_max=0.99 if action_taken != "signal_failed" else 0.30,
|
||
query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_STATUS,
|
||
confidence=confidence,
|
||
missing_fields=missing_fields,
|
||
thinking_mode=THINKING_MODE_NORMAL,
|
||
action_taken=action_taken,
|
||
signal_id=linked_signal_id,
|
||
reply_required=True,
|
||
reply_text=reply_text,
|
||
ack_latency_ms=ack_latency_ms,
|
||
trace_id=trace_id,
|
||
processing_stage="ack_only",
|
||
)
|
||
|
||
def _classify_query_type(self, question: str) -> str:
|
||
text = str(question or "").strip().lower()
|
||
if not text:
|
||
return QUERY_TYPE_UNKNOWN
|
||
# 若同一句同时出现“多少/几个”和“列出/分别”,优先按 list 处理,避免追问被误判为 count。
|
||
if any(k in text for k in LIST_HINTS):
|
||
return QUERY_TYPE_LIST
|
||
if any(k in text for k in COUNT_HINTS):
|
||
return QUERY_TYPE_COUNT
|
||
if any(k in text for k in STATUS_HINTS):
|
||
return QUERY_TYPE_STATUS
|
||
if "为什么" in text or "如何" in text or "怎么" in text:
|
||
return QUERY_TYPE_EXPLAIN
|
||
return QUERY_TYPE_UNKNOWN
|
||
|
||
async def _run_deterministic_query(
|
||
self,
|
||
*,
|
||
question: str,
|
||
project_id: Optional[int],
|
||
query_type: str,
|
||
) -> Optional[dict[str, Any]]:
|
||
q_type = str(query_type or QUERY_TYPE_UNKNOWN)
|
||
if q_type == QUERY_TYPE_COUNT:
|
||
return await self._run_count_query(question=question, project_id=project_id)
|
||
if q_type == QUERY_TYPE_LIST:
|
||
return await self._run_list_query(question=question, project_id=project_id)
|
||
if q_type == QUERY_TYPE_STATUS:
|
||
progress = await self._try_fast_project_progress_answer(clean_q=question, project_id=project_id)
|
||
if progress:
|
||
progress["query_type"] = QUERY_TYPE_STATUS
|
||
progress.setdefault("confidence", 0.98)
|
||
progress.setdefault("missing_fields", [])
|
||
return progress
|
||
existence = await self._try_fast_project_existence_answer(clean_q=question, project_id=project_id)
|
||
if existence:
|
||
existence["query_type"] = QUERY_TYPE_STATUS
|
||
existence.setdefault("confidence", 0.96)
|
||
existence.setdefault("missing_fields", [])
|
||
return existence
|
||
return None
|
||
|
||
async def _resolve_project_scope_for_question(
|
||
self,
|
||
*,
|
||
question: str,
|
||
project_id: Optional[int],
|
||
) -> tuple[Optional[int], Optional[str], str]:
|
||
if project_id is not None:
|
||
row = (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == int(project_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
return (None, None, "")
|
||
return (int(row.id), str(row.title or row.name or f"项目#{int(row.id)}"), "")
|
||
|
||
direct_project_id, direct_project_name = await self._match_project_name_in_question(question=question)
|
||
if direct_project_id is not None and direct_project_name:
|
||
return (int(direct_project_id), str(direct_project_name), str(direct_project_name)[:24])
|
||
|
||
keyword = self._extract_project_lookup_keyword(question)
|
||
terms = self._extract_query_terms(question, preferred=keyword)
|
||
for term in terms:
|
||
matched = await self._search_projects_by_keyword(keyword=term, project_id=None, limit=5)
|
||
if matched:
|
||
target = matched[0]
|
||
return (int(target.id), str(target.title or target.name or f"项目#{int(target.id)}"), term)
|
||
return (None, None, keyword[:24] if keyword else "")
|
||
|
||
@staticmethod
|
||
def _normalize_project_subject_text(text: str) -> str:
|
||
clean = str(text or "").strip().lower()
|
||
if not clean:
|
||
return ""
|
||
clean = clean.replace("—", "-").replace("_", " ")
|
||
clean = re.sub(r"[^\w\u4e00-\u9fff\- ]+", " ", clean)
|
||
clean = re.sub(
|
||
r"(我说的是|我说的|请问|请|帮我|一下|这个里面|这里面|里面|这个|那个|一下子|目前|当前|现在|有多少个|多少个|信号源|信号|项目|系统)",
|
||
" ",
|
||
clean,
|
||
)
|
||
clean = re.sub(r"[-]+", " ", clean)
|
||
clean = re.sub(r"\s+", " ", clean).strip()
|
||
return clean
|
||
|
||
async def _match_project_name_in_question(self, *, question: str) -> tuple[Optional[int], Optional[str]]:
|
||
normalized_question = self._normalize_project_subject_text(question)
|
||
if not normalized_question:
|
||
return (None, None)
|
||
|
||
projects = (
|
||
await self.db.execute(
|
||
select(Project)
|
||
.where(Project.tenant_id == self.tenant_id)
|
||
.order_by(Project.updated_at.desc(), Project.id.desc())
|
||
.limit(200)
|
||
)
|
||
).scalars().all()
|
||
|
||
best_id: Optional[int] = None
|
||
best_name: Optional[str] = None
|
||
best_score = 0
|
||
for row in projects:
|
||
candidates = [
|
||
str(row.title or "").strip(),
|
||
str(row.name or "").strip(),
|
||
]
|
||
for name in candidates:
|
||
if len(name) < 2:
|
||
continue
|
||
normalized_name = self._normalize_project_subject_text(name)
|
||
if len(normalized_name) < 2:
|
||
continue
|
||
|
||
score = 0
|
||
if normalized_name in normalized_question:
|
||
score = len(normalized_name)
|
||
else:
|
||
tokens = [t for t in normalized_name.split(" ") if len(t) >= 2]
|
||
if tokens and all(t in normalized_question for t in tokens):
|
||
score = sum(len(t) for t in tokens)
|
||
|
||
if score > best_score:
|
||
best_score = score
|
||
best_id = int(row.id)
|
||
best_name = str(row.title or row.name or f"项目#{int(row.id)}")
|
||
|
||
return (best_id, best_name)
|
||
|
||
async def _run_count_query(self, *, question: str, project_id: Optional[int]) -> Optional[dict[str, Any]]:
|
||
text = str(question or "").lower()
|
||
started = time.perf_counter()
|
||
has_project = "项目" in text
|
||
has_task = "任务" in text
|
||
has_signal = "信号" in text or "信号源" in text
|
||
has_people = self._is_people_query(text)
|
||
|
||
# 先处理更具体的对象(信号/任务),避免“xxx项目里有多少信号”误落到项目总数分支。
|
||
if has_signal:
|
||
scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question(
|
||
question=text,
|
||
project_id=project_id,
|
||
)
|
||
if has_project and scoped_project_id is None and scoped_keyword:
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
return {
|
||
"answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计信号数。",
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.94,
|
||
"metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_COUNT,
|
||
"confidence": 0.94,
|
||
"missing_fields": ["project_id"],
|
||
}
|
||
|
||
stmt = select(func.count(Signal.id)).where(Signal.tenant_id == self.tenant_id)
|
||
if scoped_project_id is not None:
|
||
stmt = stmt.where(Signal.matched_project_id == int(scoped_project_id))
|
||
total = int((await self.db.execute(stmt)).scalar() or 0)
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
if scoped_project_id is not None and scoped_project_name:
|
||
answer = f"项目「{scoped_project_name}」当前关联信号总数为 {total} 条。"
|
||
else:
|
||
answer = f"当前可见信号总数为 {total} 条。"
|
||
return {
|
||
"answer": answer,
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.99,
|
||
"metadata": {
|
||
"table": "signals",
|
||
"count": total,
|
||
"project_id": scoped_project_id,
|
||
"project_name": scoped_project_name,
|
||
},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_COUNT,
|
||
"confidence": 0.99,
|
||
"missing_fields": [],
|
||
}
|
||
|
||
if has_task:
|
||
scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question(
|
||
question=text,
|
||
project_id=project_id,
|
||
)
|
||
if has_project and scoped_project_id is None and scoped_keyword:
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
return {
|
||
"answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计任务数。",
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.94,
|
||
"metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_COUNT,
|
||
"confidence": 0.94,
|
||
"missing_fields": ["project_id"],
|
||
}
|
||
|
||
stmt = select(func.count(Task.id)).where(Task.tenant_id == self.tenant_id)
|
||
if scoped_project_id is not None:
|
||
stmt = stmt.where(Task.project_id == int(scoped_project_id))
|
||
total = int((await self.db.execute(stmt)).scalar() or 0)
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
if scoped_project_id is not None and scoped_project_name:
|
||
answer = f"项目「{scoped_project_name}」当前任务总数为 {total} 条。"
|
||
else:
|
||
answer = f"当前可见任务总数为 {total} 条。"
|
||
return {
|
||
"answer": answer,
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.99,
|
||
"metadata": {
|
||
"table": "tasks",
|
||
"count": total,
|
||
"project_id": scoped_project_id,
|
||
"project_name": scoped_project_name,
|
||
},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_COUNT,
|
||
"confidence": 0.99,
|
||
"missing_fields": [],
|
||
}
|
||
|
||
if has_people:
|
||
scoped_project_id: Optional[int] = None
|
||
scoped_project_name: Optional[str] = None
|
||
scoped_keyword = ""
|
||
if has_project or project_id is not None:
|
||
scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question(
|
||
question=text,
|
||
project_id=project_id,
|
||
)
|
||
if has_project and scoped_project_id is None and scoped_keyword and not self._is_people_query(scoped_keyword):
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
return {
|
||
"answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计成员人数。",
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.94,
|
||
"metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_COUNT,
|
||
"confidence": 0.94,
|
||
"missing_fields": ["project_id"],
|
||
}
|
||
|
||
user_stmt = select(func.count(User.id)).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.is_active.is_(True),
|
||
)
|
||
)
|
||
if scoped_project_id is not None:
|
||
member_subq = select(UserProject.user_id).where(
|
||
and_(
|
||
UserProject.tenant_id == self.tenant_id,
|
||
UserProject.project_id == int(scoped_project_id),
|
||
)
|
||
)
|
||
user_stmt = user_stmt.where(User.id.in_(member_subq))
|
||
total = int((await self.db.execute(user_stmt)).scalar() or 0)
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
if scoped_project_id is not None and scoped_project_name:
|
||
answer = f"项目「{scoped_project_name}」当前活跃成员总数为 {total} 人。"
|
||
else:
|
||
answer = f"当前系统里活跃成员总数为 {total} 人。"
|
||
return {
|
||
"answer": answer,
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "User database",
|
||
"score": 0.99,
|
||
"metadata": {
|
||
"table": "users",
|
||
"count": total,
|
||
"project_id": scoped_project_id,
|
||
"project_name": scoped_project_name,
|
||
},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_COUNT,
|
||
"confidence": 0.99,
|
||
"missing_fields": [],
|
||
}
|
||
|
||
if has_project:
|
||
stmt = select(func.count(Project.id)).where(Project.tenant_id == self.tenant_id)
|
||
if project_id is not None:
|
||
stmt = stmt.where(Project.id == int(project_id))
|
||
total = int((await self.db.execute(stmt)).scalar() or 0)
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
return {
|
||
"answer": f"当前系统里项目总数为 {total} 个。",
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.99,
|
||
"metadata": {"table": "projects", "count": total},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_COUNT,
|
||
"confidence": 0.99,
|
||
"missing_fields": [],
|
||
}
|
||
return None
|
||
|
||
async def _run_list_query(self, *, question: str, project_id: Optional[int]) -> Optional[dict[str, Any]]:
|
||
text = str(question or "").lower()
|
||
started = time.perf_counter()
|
||
if self._is_people_query(text):
|
||
scoped_project_id: Optional[int] = None
|
||
scoped_project_name: Optional[str] = None
|
||
scoped_keyword = ""
|
||
if "项目" in text or project_id is not None:
|
||
scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question(
|
||
question=text,
|
||
project_id=project_id,
|
||
)
|
||
if "项目" in text and scoped_project_id is None and scoped_keyword and not self._is_people_query(scoped_keyword):
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
return {
|
||
"answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法列出成员清单。",
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.94,
|
||
"metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_LIST,
|
||
"confidence": 0.94,
|
||
"missing_fields": ["project_id"],
|
||
}
|
||
|
||
user_stmt = select(User).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.is_active.is_(True),
|
||
)
|
||
)
|
||
if scoped_project_id is not None:
|
||
member_subq = select(UserProject.user_id).where(
|
||
and_(
|
||
UserProject.tenant_id == self.tenant_id,
|
||
UserProject.project_id == int(scoped_project_id),
|
||
)
|
||
)
|
||
user_stmt = user_stmt.where(User.id.in_(member_subq))
|
||
members = (
|
||
await self.db.execute(
|
||
user_stmt.order_by(User.updated_at.desc(), User.id.desc()).limit(30)
|
||
)
|
||
).scalars().all()
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
if not members:
|
||
answer = (
|
||
f"项目「{scoped_project_name}」当前没有可列举的成员。"
|
||
if scoped_project_name
|
||
else "当前没有可列举的成员。"
|
||
)
|
||
return {
|
||
"answer": answer,
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "User database",
|
||
"score": 0.95,
|
||
"metadata": {
|
||
"table": "users",
|
||
"count": 0,
|
||
"project_id": scoped_project_id,
|
||
"project_name": scoped_project_name,
|
||
},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_LIST,
|
||
"confidence": 0.95,
|
||
"missing_fields": [],
|
||
}
|
||
|
||
member_ids = [int(row.id) for row in members]
|
||
workload_rows = (
|
||
await self.db.execute(
|
||
select(UserWorkload.user_id, UserWorkload.workload_ratio).where(
|
||
and_(
|
||
UserWorkload.tenant_id == self.tenant_id,
|
||
UserWorkload.user_id.in_(member_ids),
|
||
)
|
||
)
|
||
)
|
||
).all()
|
||
workload_map = {int(uid): float(ratio or 0.0) for uid, ratio in workload_rows}
|
||
|
||
project_count_rows = (
|
||
await self.db.execute(
|
||
select(UserProject.user_id, func.count(UserProject.project_id))
|
||
.where(
|
||
and_(
|
||
UserProject.tenant_id == self.tenant_id,
|
||
UserProject.user_id.in_(member_ids),
|
||
)
|
||
)
|
||
.group_by(UserProject.user_id)
|
||
)
|
||
).all()
|
||
project_count_map = {int(uid): int(cnt or 0) for uid, cnt in project_count_rows}
|
||
|
||
lines = []
|
||
for row in members:
|
||
uid = int(row.id)
|
||
ratio = workload_map.get(uid)
|
||
if ratio is None:
|
||
ratio = round(float(row.workload or 0) / 100.0, 2)
|
||
role = str(row.role or "unknown")
|
||
project_count = project_count_map.get(uid, 0)
|
||
lines.append(
|
||
f"#{uid} {str(row.name or f'User#{uid}')}(角色:{role},负载:{float(ratio):.2f},关联项目:{project_count})"
|
||
)
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
header = f"项目「{scoped_project_name}」成员清单如下:" if scoped_project_name else "当前团队成员清单如下:"
|
||
return {
|
||
"answer": header + "\n" + "\n".join(lines),
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "User database",
|
||
"score": 0.99,
|
||
"metadata": {
|
||
"table": "users",
|
||
"count": len(members),
|
||
"project_id": scoped_project_id,
|
||
"project_name": scoped_project_name,
|
||
},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_LIST,
|
||
"confidence": 0.99,
|
||
"missing_fields": [],
|
||
}
|
||
|
||
if "信号" in text or "信号源" in text:
|
||
scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question(
|
||
question=text,
|
||
project_id=project_id,
|
||
)
|
||
if "项目" in text and scoped_project_id is None and scoped_keyword:
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
return {
|
||
"answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法列出信号清单。",
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.94,
|
||
"metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_LIST,
|
||
"confidence": 0.94,
|
||
"missing_fields": ["project_id"],
|
||
}
|
||
|
||
stmt = select(Signal).where(Signal.tenant_id == self.tenant_id)
|
||
if scoped_project_id is not None:
|
||
stmt = stmt.where(Signal.matched_project_id == int(scoped_project_id))
|
||
signals = (
|
||
await self.db.execute(stmt.order_by(Signal.updated_at.desc(), Signal.id.desc()).limit(30))
|
||
).scalars().all()
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
if not signals:
|
||
answer = (
|
||
f"项目「{scoped_project_name}」当前没有可列举的信号。"
|
||
if scoped_project_name
|
||
else "当前没有可列举的信号。"
|
||
)
|
||
return {
|
||
"answer": answer,
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.95,
|
||
"metadata": {
|
||
"table": "signals",
|
||
"count": 0,
|
||
"project_id": scoped_project_id,
|
||
"project_name": scoped_project_name,
|
||
},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_LIST,
|
||
"confidence": 0.95,
|
||
"missing_fields": [],
|
||
}
|
||
|
||
lines = []
|
||
for row in signals:
|
||
title = str(row.title or row.summary or row.content or f"Signal#{int(row.id)}").replace("\n", " ").strip()
|
||
status = str(row.status or "unknown")
|
||
lines.append(f"#{int(row.id)} {title[:90]}({status})")
|
||
header = f"项目「{scoped_project_name}」信号清单如下:" if scoped_project_name else "当前信号清单如下:"
|
||
return {
|
||
"answer": header + "\n" + "\n".join(lines),
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.99,
|
||
"metadata": {
|
||
"table": "signals",
|
||
"count": len(signals),
|
||
"project_id": scoped_project_id,
|
||
"project_name": scoped_project_name,
|
||
},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_LIST,
|
||
"confidence": 0.99,
|
||
"missing_fields": [],
|
||
}
|
||
|
||
if "项目" in text:
|
||
stmt = select(Project).where(Project.tenant_id == self.tenant_id)
|
||
if project_id is not None:
|
||
stmt = stmt.where(Project.id == int(project_id))
|
||
projects = (
|
||
await self.db.execute(stmt.order_by(Project.updated_at.desc(), Project.id.desc()).limit(20))
|
||
).scalars().all()
|
||
if not projects:
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
return {
|
||
"answer": "当前没有可列举的项目。",
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.95,
|
||
"metadata": {"table": "projects", "count": 0},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_LIST,
|
||
"confidence": 0.95,
|
||
"missing_fields": [],
|
||
}
|
||
|
||
lines = [f"#{int(p.id)} {str(p.title or p.name or '未命名项目')}({str(p.status or 'unknown')})" for p in projects]
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
return {
|
||
"answer": "当前项目列表如下:\n" + "\n".join(lines),
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.99,
|
||
"metadata": {"table": "projects", "count": len(projects)},
|
||
}
|
||
],
|
||
"db_query_ms": elapsed,
|
||
"response_time_ms": elapsed,
|
||
"query_type": QUERY_TYPE_LIST,
|
||
"confidence": 0.99,
|
||
"missing_fields": [],
|
||
}
|
||
return None
|
||
|
||
async def _ensure_session_state(self, *, session_id: Optional[int]) -> Optional[QASessionState]:
|
||
if session_id is None:
|
||
return None
|
||
row = (
|
||
await self.db.execute(
|
||
select(QASessionState).where(
|
||
and_(
|
||
QASessionState.tenant_id == self.tenant_id,
|
||
QASessionState.session_id == int(session_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
row = QASessionState(
|
||
tenant_id=self.tenant_id,
|
||
session_id=int(session_id),
|
||
last_subject=None,
|
||
last_entity_ids=[],
|
||
last_intent=None,
|
||
ambiguity="unknown",
|
||
rewrite_history=[],
|
||
)
|
||
self.db.add(row)
|
||
await self.db.flush()
|
||
return row
|
||
|
||
def _rewrite_question_with_state(
|
||
self,
|
||
*,
|
||
question: str,
|
||
conversation: list[dict[str, str]],
|
||
session_state: Optional[QASessionState],
|
||
) -> tuple[str, dict[str, Any]]:
|
||
expanded = self._expand_followup_question(question, conversation)
|
||
current = str(expanded or question or "").strip()
|
||
if not current:
|
||
return current, {"mode": "empty"}
|
||
|
||
lower = current.lower()
|
||
has_referential = any(k in lower for k in FOLLOWUP_REFERENTIAL_KEYWORDS)
|
||
if not has_referential:
|
||
return current, {"mode": "direct"}
|
||
|
||
state_subject = ""
|
||
state_entity_ids: list[int] = []
|
||
if session_state is not None:
|
||
state_subject = str(session_state.last_subject or "").strip()
|
||
if isinstance(session_state.last_entity_ids, list):
|
||
for value in session_state.last_entity_ids:
|
||
try:
|
||
state_entity_ids.append(int(value))
|
||
except Exception:
|
||
continue
|
||
if state_subject:
|
||
rewritten = f"围绕“{state_subject}”回答:{current}"
|
||
return rewritten[:1200], {"mode": "state_subject", "subject": state_subject}
|
||
if state_entity_ids:
|
||
anchor = ",".join(str(x) for x in state_entity_ids[:6])
|
||
rewritten = f"围绕会话实体ID[{anchor}]回答:{current}"
|
||
return rewritten[:1200], {"mode": "state_entities", "entity_ids": state_entity_ids[:6]}
|
||
return current, {"mode": "referential_without_subject"}
|
||
|
||
def _build_clarification_question(
|
||
self,
|
||
*,
|
||
original_question: str,
|
||
effective_question: str,
|
||
session_state: Optional[QASessionState],
|
||
) -> Optional[str]:
|
||
text = str(original_question or "").strip().lower()
|
||
if not text:
|
||
return None
|
||
if self._is_correction_query(text):
|
||
if self._has_explicit_target_scope(text):
|
||
return None
|
||
return (
|
||
"收到,你在纠正上一条回答。请直接说你要的目标范围,例如:"
|
||
"`我问的是 AI 引擎驱动方式,第一层是否调用 openclaw?`"
|
||
)
|
||
has_referential = any(k in text for k in FOLLOWUP_REFERENTIAL_KEYWORDS)
|
||
if not has_referential:
|
||
return None
|
||
state_subject = str(getattr(session_state, "last_subject", "") or "").strip()
|
||
state_entity_ids = getattr(session_state, "last_entity_ids", None)
|
||
has_state_entities = isinstance(state_entity_ids, list) and any(str(x).strip() for x in state_entity_ids)
|
||
if state_subject or has_state_entities:
|
||
return None
|
||
if str(effective_question or "").strip() != text and "补充问题" in str(effective_question):
|
||
return None
|
||
return "你这句缺少明确主语。请先指明对象,例如:`分别是哪些项目?` 或 `请列出这些项目的名称和ID`。"
|
||
|
||
async def _update_session_state_after_turn(
|
||
self,
|
||
*,
|
||
session_state: Optional[QASessionState],
|
||
question: str,
|
||
effective_question: str,
|
||
answer: str,
|
||
query_type: str,
|
||
ambiguity: str,
|
||
rewrite_meta: dict[str, Any],
|
||
sources: list[dict[str, Any]],
|
||
) -> None:
|
||
if session_state is None:
|
||
return
|
||
subject = self._extract_state_subject(question=question, answer=answer, sources=sources)
|
||
if subject:
|
||
session_state.last_subject = subject[:255]
|
||
session_state.last_intent = "question"
|
||
session_state.ambiguity = str(ambiguity or "clear")[:20]
|
||
session_state.last_entity_ids = self._collect_entity_ids_from_sources(sources)
|
||
|
||
history = session_state.rewrite_history if isinstance(session_state.rewrite_history, list) else []
|
||
history.append(
|
||
{
|
||
"at": datetime.now(timezone.utc).isoformat(),
|
||
"question": question[:500],
|
||
"effective_question": effective_question[:500],
|
||
"query_type": str(query_type or QUERY_TYPE_UNKNOWN),
|
||
"ambiguity": str(ambiguity or "clear"),
|
||
"rewrite_meta": rewrite_meta if isinstance(rewrite_meta, dict) else {},
|
||
}
|
||
)
|
||
session_state.rewrite_history = history[-20:]
|
||
await self.db.flush()
|
||
|
||
def _collect_entity_ids_from_sources(self, sources: list[dict[str, Any]]) -> list[int]:
|
||
ids: list[int] = []
|
||
for source in sources:
|
||
if not isinstance(source, dict):
|
||
continue
|
||
meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {}
|
||
for key in (
|
||
"project_id",
|
||
"task_id",
|
||
"signal_id",
|
||
"user_id",
|
||
"entity_id",
|
||
"subject_entity_id",
|
||
"object_entity_id",
|
||
):
|
||
value = meta.get(key)
|
||
try:
|
||
if value is not None:
|
||
ids.append(int(value))
|
||
except Exception:
|
||
continue
|
||
matched = meta.get("matched_project_ids")
|
||
if isinstance(matched, list):
|
||
for value in matched:
|
||
try:
|
||
ids.append(int(value))
|
||
except Exception:
|
||
continue
|
||
uniq: list[int] = []
|
||
for value in ids:
|
||
if value not in uniq:
|
||
uniq.append(value)
|
||
return uniq[:20]
|
||
|
||
def _extract_state_subject(
|
||
self,
|
||
*,
|
||
question: str,
|
||
answer: str,
|
||
sources: list[dict[str, Any]],
|
||
) -> str:
|
||
for source in sources:
|
||
if not isinstance(source, dict):
|
||
continue
|
||
meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {}
|
||
project_keyword = str(meta.get("query_keyword") or "").strip()
|
||
if project_keyword:
|
||
return project_keyword[:255]
|
||
subject_name = str(meta.get("subject_name") or "").strip()
|
||
object_name = str(meta.get("object_name") or "").strip()
|
||
if subject_name and object_name:
|
||
return f"{subject_name}->{object_name}"[:255]
|
||
|
||
text = str(question or "").strip()
|
||
keyword = self._extract_project_lookup_keyword(text)
|
||
if len(keyword) >= 2:
|
||
return keyword[:255]
|
||
|
||
answer_text = str(answer or "").strip()
|
||
m = re.search(r"项目[「\"]?([^」\"\n]{2,40})", answer_text)
|
||
if m:
|
||
return str(m.group(1)).strip()[:255]
|
||
return ""
|
||
|
||
async def get_session_state_replay(self, *, session_id: int, user_id: int) -> dict[str, Any]:
|
||
session = (
|
||
await self.db.execute(
|
||
select(QASession).where(
|
||
and_(
|
||
QASession.tenant_id == self.tenant_id,
|
||
QASession.id == int(session_id),
|
||
QASession.user_id == int(user_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if session is None:
|
||
raise ValueError("qa session 不存在")
|
||
|
||
state = await self._ensure_session_state(session_id=int(session_id))
|
||
history = state.rewrite_history if isinstance(state.rewrite_history, list) else []
|
||
return {
|
||
"session_id": int(session_id),
|
||
"last_subject": str(state.last_subject or "") if state else "",
|
||
"last_entity_ids": state.last_entity_ids if state else [],
|
||
"last_intent": str(state.last_intent or "") if state else "",
|
||
"ambiguity": str(state.ambiguity or "") if state else "",
|
||
"rewrite_history": history[-20:],
|
||
"updated_at": state.updated_at.isoformat() if state and state.updated_at else None,
|
||
}
|
||
|
||
async def _try_glm_project_semantic_answer(
|
||
self,
|
||
*,
|
||
clean_q: str,
|
||
project_id: Optional[int],
|
||
) -> Optional[dict[str, Any]]:
|
||
question = str(clean_q or "").strip()
|
||
if not question:
|
||
return None
|
||
if self._is_people_query(question):
|
||
return None
|
||
if not any(k in question for k in ("项目", "系统", "进度", "状态", "有没有", "是否", "存在", "立项", "任务")):
|
||
return None
|
||
|
||
db_started = time.perf_counter()
|
||
stmt = select(Project).where(Project.tenant_id == self.tenant_id)
|
||
if project_id is not None:
|
||
stmt = stmt.where(Project.id == int(project_id))
|
||
projects = (await self.db.execute(stmt.order_by(Project.updated_at.desc()).limit(120))).scalars().all()
|
||
if not projects:
|
||
db_query_ms = int((time.perf_counter() - db_started) * 1000)
|
||
return {
|
||
"answer": "当前项目库为空,无法检索到匹配项目。",
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.99,
|
||
"metadata": {"table": "projects", "match_count": 0, "retrieval_mode": "glm_semantic"},
|
||
}
|
||
],
|
||
"db_query_ms": db_query_ms,
|
||
"response_time_ms": db_query_ms,
|
||
}
|
||
|
||
project_ids = [int(p.id) for p in projects]
|
||
task_rows = []
|
||
if project_ids:
|
||
task_rows = (
|
||
await self.db.execute(
|
||
select(Task).where(
|
||
and_(
|
||
Task.tenant_id == self.tenant_id,
|
||
Task.project_id.in_(project_ids),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
db_query_ms = int((time.perf_counter() - db_started) * 1000)
|
||
|
||
done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"}
|
||
doing_statuses = {"in_progress", "doing", "进行中", "开发中", "处理中", "processing"}
|
||
pending_statuses = {"todo", "pending", "待处理", "未开始", "open"}
|
||
|
||
task_stats: dict[int, dict[str, Any]] = {}
|
||
for pid in project_ids:
|
||
task_stats[pid] = {
|
||
"task_total": 0,
|
||
"done_count": 0,
|
||
"doing_count": 0,
|
||
"pending_count": 0,
|
||
"latest_task_updated_at": None,
|
||
}
|
||
|
||
for row in task_rows:
|
||
pid = int(row.project_id)
|
||
if pid not in task_stats:
|
||
continue
|
||
stat = task_stats[pid]
|
||
stat["task_total"] += 1
|
||
status = str(row.status or "").strip().lower()
|
||
if status in done_statuses:
|
||
stat["done_count"] += 1
|
||
elif status in doing_statuses:
|
||
stat["doing_count"] += 1
|
||
elif status in pending_statuses:
|
||
stat["pending_count"] += 1
|
||
else:
|
||
stat["pending_count"] += 1
|
||
row_time = row.updated_at or row.created_at
|
||
latest = stat.get("latest_task_updated_at")
|
||
if row_time and (latest is None or row_time > latest):
|
||
stat["latest_task_updated_at"] = row_time
|
||
|
||
project_index: list[dict[str, Any]] = []
|
||
for p in projects:
|
||
pid = int(p.id)
|
||
stats = task_stats.get(pid) or {}
|
||
project_index.append(
|
||
{
|
||
"id": pid,
|
||
"title": str(p.title or p.name or "")[:120],
|
||
"name": str(p.name or "")[:120],
|
||
"status": str(p.status or "unknown"),
|
||
"description": str(p.description or "")[:220],
|
||
"task_total": int(stats.get("task_total") or 0),
|
||
"done_count": int(stats.get("done_count") or 0),
|
||
"doing_count": int(stats.get("doing_count") or 0),
|
||
"pending_count": int(stats.get("pending_count") or 0),
|
||
"latest_task_updated_at": (
|
||
stats.get("latest_task_updated_at").isoformat()
|
||
if stats.get("latest_task_updated_at") is not None
|
||
else None
|
||
),
|
||
}
|
||
)
|
||
|
||
prompt = PROMPT_RAG_DB_PROJECT_QA.format(
|
||
question=question[:600],
|
||
project_index=json.dumps(project_index, ensure_ascii=False),
|
||
).strip()
|
||
llm_started = time.perf_counter()
|
||
try:
|
||
raw = await self.llm.generate(
|
||
prompt,
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
function_name="rag_db_project_semantic_retrieval",
|
||
temperature=0.0,
|
||
max_tokens=520,
|
||
use_prompt_profile=False,
|
||
)
|
||
parsed = self._parse_json_object(raw)
|
||
if not parsed:
|
||
return None
|
||
handled = bool(parsed.get("handled"))
|
||
answer = str(parsed.get("answer") or "").strip()
|
||
confidence = self._to_float(parsed.get("confidence"), 0.0)
|
||
matched_ids_raw = parsed.get("matched_project_ids")
|
||
matched_ids: list[int] = []
|
||
if isinstance(matched_ids_raw, list):
|
||
for x in matched_ids_raw:
|
||
try:
|
||
matched_ids.append(int(x))
|
||
except Exception:
|
||
continue
|
||
matched_ids = matched_ids[:8]
|
||
if not handled or not answer:
|
||
return None
|
||
response_ms = int((time.perf_counter() - llm_started) * 1000) + db_query_ms
|
||
return {
|
||
"answer": answer,
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": round(max(0.0, min(1.0, confidence or 0.9)), 4),
|
||
"metadata": {
|
||
"table": "projects",
|
||
"matched_project_ids": matched_ids,
|
||
"project_index_count": len(project_index),
|
||
"retrieval_mode": "glm_semantic",
|
||
},
|
||
}
|
||
],
|
||
"db_query_ms": db_query_ms,
|
||
"response_time_ms": response_ms,
|
||
}
|
||
except Exception:
|
||
return None
|
||
|
||
async def _persist_fast_answer(
|
||
self,
|
||
*,
|
||
question: str,
|
||
asked_by: Optional[int],
|
||
asked_via: str,
|
||
project_id: Optional[int],
|
||
fast_result: dict[str, Any],
|
||
session_id: Optional[int] = None,
|
||
external_session_id: Optional[int] = None,
|
||
turn_index: Optional[int] = None,
|
||
query_type: str = QUERY_TYPE_EXPLAIN,
|
||
missing_fields: Optional[list[str]] = None,
|
||
state_row: Optional[QASessionState] = None,
|
||
effective_question: Optional[str] = None,
|
||
rewrite_meta: Optional[dict[str, Any]] = None,
|
||
thinking_mode: str = THINKING_MODE_NORMAL,
|
||
) -> dict[str, Any]:
|
||
started = time.perf_counter()
|
||
answer_text = str(fast_result.get("answer") or "未命中结果")
|
||
sources = list(fast_result.get("sources") or [])
|
||
active_session_id = session_id
|
||
active_turn_index = turn_index
|
||
if active_session_id is None or active_turn_index is None:
|
||
active_session_id, active_turn_index = await self._touch_session_for_new_record(
|
||
asked_by=asked_by,
|
||
asked_via=asked_via,
|
||
project_id=project_id,
|
||
question=question,
|
||
)
|
||
active_turn_index = int(active_turn_index or 1)
|
||
active_session_id_int = int(active_session_id) if active_session_id is not None else None
|
||
|
||
confidence = self._to_float(fast_result.get("confidence"), 0.96)
|
||
if missing_fields is None:
|
||
missing_fields = list(fast_result.get("missing_fields") or [])
|
||
final_query_type = str(fast_result.get("query_type") or query_type or QUERY_TYPE_EXPLAIN)
|
||
|
||
qa = QARecord(
|
||
tenant_id=self.tenant_id,
|
||
question=question,
|
||
answer=answer_text,
|
||
sources=sources,
|
||
intent="question",
|
||
domains_used=["db"],
|
||
response_time_ms=int(fast_result.get("response_time_ms") or 0),
|
||
asked_by=asked_by,
|
||
asked_via=(asked_via or "pmflow")[:20],
|
||
project_id=project_id,
|
||
session_id=active_session_id_int,
|
||
turn_index=active_turn_index,
|
||
)
|
||
self.db.add(qa)
|
||
if state_row is None and active_session_id_int is not None:
|
||
state_row = await self._ensure_session_state(session_id=active_session_id_int)
|
||
await self._update_session_state_after_turn(
|
||
session_state=state_row,
|
||
question=question,
|
||
effective_question=str(effective_question or question),
|
||
answer=answer_text,
|
||
query_type=final_query_type,
|
||
ambiguity="clear",
|
||
rewrite_meta=rewrite_meta or {},
|
||
sources=sources,
|
||
)
|
||
await self.db.commit()
|
||
await self.db.refresh(qa)
|
||
cache_session_id = active_session_id_int if active_session_id_int is not None else (
|
||
int(external_session_id) if external_session_id is not None else None
|
||
)
|
||
await self.context_cache.append_session_messages(
|
||
tenant_id=self.tenant_id,
|
||
session_id=cache_session_id,
|
||
question=question,
|
||
answer=answer_text,
|
||
)
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
return self._build_response_payload(
|
||
answer=answer_text,
|
||
sources=sources,
|
||
domains_used=["db"],
|
||
response_time_ms=int(fast_result.get("response_time_ms") or elapsed),
|
||
db_query_ms=int(fast_result.get("db_query_ms") or 0),
|
||
vector_search_ms=0,
|
||
db_timeout=False,
|
||
vector_timeout=False,
|
||
qa_id=int(qa.id),
|
||
session_id=active_session_id_int,
|
||
turn_index=active_turn_index,
|
||
relevance_max=0.99,
|
||
query_type=final_query_type,
|
||
confidence=confidence,
|
||
missing_fields=list(missing_fields or []),
|
||
thinking_mode=thinking_mode,
|
||
)
|
||
|
||
async def _try_fast_project_progress_answer(
|
||
self,
|
||
*,
|
||
clean_q: str,
|
||
project_id: Optional[int],
|
||
) -> Optional[dict[str, Any]]:
|
||
question = str(clean_q or "").strip()
|
||
if not question:
|
||
return None
|
||
if not PROJECT_PROGRESS_RE.search(question):
|
||
return None
|
||
if "项目" not in question and "系统" not in question:
|
||
return None
|
||
|
||
primary_keyword = self._extract_project_lookup_keyword(question)
|
||
query_terms = self._extract_query_terms(question, preferred=primary_keyword)
|
||
if not query_terms:
|
||
return None
|
||
|
||
started = time.perf_counter()
|
||
matched: list[Project] = []
|
||
used_keyword = query_terms[0]
|
||
for term in query_terms:
|
||
rows = await self._search_projects_by_keyword(keyword=term, project_id=project_id, limit=3)
|
||
if rows:
|
||
matched = rows
|
||
used_keyword = term
|
||
break
|
||
if not matched:
|
||
db_query_ms = int((time.perf_counter() - started) * 1000)
|
||
return {
|
||
"answer": f"未检索到名称或描述包含“{used_keyword}”的项目,无法判断进度。",
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.99,
|
||
"metadata": {"table": "projects", "query_keyword": used_keyword, "match_count": 0},
|
||
}
|
||
],
|
||
"db_query_ms": db_query_ms,
|
||
"response_time_ms": db_query_ms,
|
||
}
|
||
|
||
target = matched[0]
|
||
task_rows = (
|
||
await self.db.execute(
|
||
select(Task).where(
|
||
and_(
|
||
Task.tenant_id == self.tenant_id,
|
||
Task.project_id == int(target.id),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
db_query_ms = int((time.perf_counter() - started) * 1000)
|
||
|
||
total = len(task_rows)
|
||
done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"}
|
||
doing_statuses = {"in_progress", "doing", "进行中", "开发中", "处理中", "processing"}
|
||
pending_statuses = {"todo", "pending", "待处理", "未开始", "open"}
|
||
|
||
done = 0
|
||
doing = 0
|
||
pending = 0
|
||
latest_update: Optional[datetime] = None
|
||
for row in task_rows:
|
||
status = str(row.status or "").strip().lower()
|
||
if status in done_statuses:
|
||
done += 1
|
||
elif status in doing_statuses:
|
||
doing += 1
|
||
elif status in pending_statuses:
|
||
pending += 1
|
||
else:
|
||
pending += 1
|
||
|
||
row_time = row.updated_at or row.created_at
|
||
if row_time and (latest_update is None or row_time > latest_update):
|
||
latest_update = row_time
|
||
|
||
project_name = str(target.title or target.name or f"项目#{int(target.id)}")
|
||
project_status = str(target.status or "unknown")
|
||
if total == 0:
|
||
answer = (
|
||
f"项目「{project_name}」当前状态为 {project_status}。"
|
||
"目前未检索到该项目的任务拆解记录,因此只能确认项目状态,无法量化阶段进度。"
|
||
)
|
||
else:
|
||
completion = round((done / total) * 100, 1)
|
||
latest_text = latest_update.strftime("%Y-%m-%d %H:%M") if latest_update else "未知"
|
||
answer = (
|
||
f"项目「{project_name}」当前状态为 {project_status}。"
|
||
f"任务总数 {total},已完成 {done},进行中 {doing},待处理 {pending},完成率 {completion}%。"
|
||
f"最近任务更新时间:{latest_text}。"
|
||
)
|
||
|
||
if len(matched) > 1:
|
||
others = "、".join([str(x.title or x.name or f'#{x.id}') for x in matched[1:]])
|
||
answer += f" 另有 {len(matched) - 1} 个同名候选项目:{others}。"
|
||
|
||
return {
|
||
"answer": answer,
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.99,
|
||
"metadata": {
|
||
"table": "projects",
|
||
"project_id": int(target.id),
|
||
"query_keyword": used_keyword,
|
||
"match_count": len(matched),
|
||
},
|
||
},
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.98,
|
||
"metadata": {
|
||
"table": "tasks",
|
||
"project_id": int(target.id),
|
||
"task_total": total,
|
||
"done_count": done,
|
||
"doing_count": doing,
|
||
"pending_count": pending,
|
||
},
|
||
},
|
||
],
|
||
"db_query_ms": db_query_ms,
|
||
"response_time_ms": db_query_ms,
|
||
}
|
||
|
||
async def _try_fast_project_existence_answer(
|
||
self,
|
||
*,
|
||
clean_q: str,
|
||
project_id: Optional[int],
|
||
) -> Optional[dict[str, Any]]:
|
||
question = str(clean_q or "").strip()
|
||
if not question:
|
||
return None
|
||
if "项目" not in question and "系统" not in question:
|
||
return None
|
||
if (
|
||
"吗" not in question
|
||
and "?" not in question
|
||
and "?" not in question
|
||
and "是否有" not in question
|
||
and "有没有" not in question
|
||
and "有无" not in question
|
||
and not PROJECT_EXISTENCE_RE.search(question)
|
||
):
|
||
return None
|
||
|
||
primary_keyword = self._extract_project_lookup_keyword(question)
|
||
query_terms = self._extract_query_terms(question, preferred=primary_keyword)
|
||
if not query_terms:
|
||
return None
|
||
|
||
started = time.perf_counter()
|
||
matched: list[Project] = []
|
||
used_keyword = query_terms[0]
|
||
for term in query_terms:
|
||
rows = await self._search_projects_by_keyword(keyword=term, project_id=project_id, limit=5)
|
||
if rows:
|
||
matched = rows
|
||
used_keyword = term
|
||
break
|
||
db_query_ms = int((time.perf_counter() - started) * 1000)
|
||
|
||
if matched:
|
||
lines = [
|
||
f"#{int(p.id)} {str(p.title or p.name or '未命名项目')}(状态:{str(p.status or 'unknown')})"
|
||
for p in matched
|
||
]
|
||
answer = f"有,当前匹配到 {len(matched)} 个相关项目:\n" + "\n".join(lines)
|
||
else:
|
||
answer = f"暂未检索到名称或描述包含“{used_keyword}”的项目。"
|
||
|
||
return {
|
||
"answer": answer,
|
||
"sources": [
|
||
{
|
||
"source": "db",
|
||
"label": "Project database",
|
||
"score": 0.99,
|
||
"metadata": {
|
||
"table": "projects",
|
||
"query_keyword": used_keyword,
|
||
"match_count": len(matched),
|
||
},
|
||
}
|
||
],
|
||
"db_query_ms": db_query_ms,
|
||
}
|
||
|
||
async def _search_projects_by_keyword(
|
||
self,
|
||
*,
|
||
keyword: str,
|
||
project_id: Optional[int],
|
||
limit: int,
|
||
) -> list[Project]:
|
||
term = str(keyword or "").strip()
|
||
if not term:
|
||
return []
|
||
stmt = select(Project).where(Project.tenant_id == self.tenant_id)
|
||
if project_id is not None:
|
||
stmt = stmt.where(Project.id == int(project_id))
|
||
like = f"%{term}%"
|
||
stmt = stmt.where(
|
||
or_(
|
||
Project.name.ilike(like),
|
||
Project.title.ilike(like),
|
||
Project.description.ilike(like),
|
||
)
|
||
)
|
||
return (await self.db.execute(stmt.limit(max(1, int(limit))))).scalars().all()
|
||
|
||
@staticmethod
|
||
def _extract_project_lookup_keyword(question: str) -> str:
|
||
text = str(question or "").strip()
|
||
if not text:
|
||
return ""
|
||
text = text.replace("?", "?")
|
||
|
||
direct_patterns = [
|
||
r"(?:是否有|有没有|有无)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?",
|
||
r"(?:是否存在|存在)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?",
|
||
]
|
||
for pattern in direct_patterns:
|
||
m = re.search(pattern, text)
|
||
if m:
|
||
value = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()")
|
||
value = QUESTION_NOISE_RE.sub("", value).strip()
|
||
if len(value) >= 2:
|
||
return value[:24]
|
||
|
||
anchored_matches = list(re.finditer(r"([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)", text))
|
||
for m in reversed(anchored_matches):
|
||
value = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()")
|
||
value = QUESTION_NOISE_RE.sub("", value).strip()
|
||
if len(value) >= 2:
|
||
return value[:24]
|
||
|
||
patterns = [
|
||
r"有(?:没有)?(.{1,24}?)(?:项目|系统).*\?",
|
||
r"项目.*?有(?:没有)?(.{1,24}?)(?:吗|\?)",
|
||
r"是否有(.{1,24}?)(?:项目|系统)",
|
||
r"(.{1,24}?)(?:项目|系统)(?:有吗|有没有|是否有)",
|
||
]
|
||
for pattern in patterns:
|
||
m = re.search(pattern, text)
|
||
if not m:
|
||
continue
|
||
kw = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()")
|
||
kw = QUESTION_NOISE_RE.sub("", kw).strip()
|
||
if kw:
|
||
return kw[:24]
|
||
|
||
fallback = re.sub(r"[??]", " ", text)
|
||
fallback = re.sub(
|
||
r"(项目|系统|目前|当前|现在|我们|有没有|是否有|有吗|有|吗|什么|进度|层面|阶段|是在|在|是|请问|一下|多少)",
|
||
" ",
|
||
fallback,
|
||
)
|
||
fallback = re.sub(r"\s+", " ", fallback).strip(" ,。,.!??::;;")
|
||
return fallback[:24]
|
||
|
||
@staticmethod
|
||
def _extract_query_terms(question: str, *, preferred: str = "") -> list[str]:
|
||
base = str(question or "").strip()
|
||
terms: list[str] = []
|
||
pref = str(preferred or "").strip()
|
||
if len(pref) >= 2:
|
||
terms.append(pref[:24])
|
||
|
||
clean = re.sub(r"[??,,。!!::;;()()\"“”']", " ", base)
|
||
clean = QUESTION_NOISE_RE.sub(" ", clean)
|
||
clean = re.sub(r"(项目|系统|进度|状态|阶段|完成率|层面)", " ", clean)
|
||
clean = re.sub(r"\s+", " ", clean).strip()
|
||
|
||
token_candidates: list[str] = []
|
||
for token in clean.split(" "):
|
||
t = token.strip()
|
||
if len(t) >= 2:
|
||
token_candidates.append(t[:24])
|
||
|
||
for m in re.finditer(r"[\u4e00-\u9fff]{2,10}", clean):
|
||
token_candidates.append(m.group(0)[:24])
|
||
|
||
for t in sorted(token_candidates, key=len, reverse=True):
|
||
if t and t not in terms:
|
||
terms.append(t)
|
||
return terms[:6]
|
||
|
||
@staticmethod
|
||
def _is_people_query(question: str) -> bool:
|
||
text = str(question or "").strip().lower()
|
||
if not text:
|
||
return False
|
||
return any(k in text for k in PEOPLE_KEYWORDS)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Manifest / Entity resolution / Agentic retrieval
|
||
# ------------------------------------------------------------------
|
||
async def get_project_manifest(self, *, project_id: Optional[int] = None) -> dict[str, Any]:
|
||
system_snapshot = await self.context_cache.get_system_snapshot(
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
)
|
||
symbol_snapshot = await self._get_symbol_snapshot_cached()
|
||
|
||
project_info: dict[str, Any] = {}
|
||
if project_id is not None:
|
||
row = (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == int(project_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is not None:
|
||
project_info = {
|
||
"id": int(row.id),
|
||
"title": str(row.title or row.name or f"项目#{int(row.id)}"),
|
||
"status": str(row.status or "unknown"),
|
||
}
|
||
|
||
kg_overview = {"entity_total": 0, "relation_total": 0, "evidence_total": 0}
|
||
try:
|
||
kg_overview = {
|
||
"entity_total": int(
|
||
(await self.db.execute(select(func.count(KGEntity.id)).where(KGEntity.tenant_id == self.tenant_id)))
|
||
.scalar_one()
|
||
or 0
|
||
),
|
||
"relation_total": int(
|
||
(
|
||
await self.db.execute(select(func.count(KGRelation.id)).where(KGRelation.tenant_id == self.tenant_id))
|
||
).scalar_one()
|
||
or 0
|
||
),
|
||
"evidence_total": int(
|
||
(
|
||
await self.db.execute(select(func.count(KGEvidence.id)).where(KGEvidence.tenant_id == self.tenant_id))
|
||
).scalar_one()
|
||
or 0
|
||
),
|
||
}
|
||
except Exception:
|
||
kg_overview = {"entity_total": 0, "relation_total": 0, "evidence_total": 0}
|
||
|
||
platform = (
|
||
system_snapshot.get("platform_profile")
|
||
if isinstance(system_snapshot.get("platform_profile"), dict)
|
||
else {}
|
||
)
|
||
backend = platform.get("backend") if isinstance(platform.get("backend"), dict) else {}
|
||
module_counts = backend.get("module_counts") if isinstance(backend.get("module_counts"), dict) else {}
|
||
feature_catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else []
|
||
feature_summary = {
|
||
"feature_total": len(feature_catalog),
|
||
"top_features": [
|
||
{
|
||
"name": str(item.get("name") or ""),
|
||
"route": str(item.get("route") or ""),
|
||
}
|
||
for item in feature_catalog[:10]
|
||
if isinstance(item, dict)
|
||
],
|
||
}
|
||
|
||
return {
|
||
"tenant_id": self.tenant_id,
|
||
"project": project_info,
|
||
"module_counts": module_counts,
|
||
"feature_summary": feature_summary,
|
||
"symbol_summary": symbol_snapshot,
|
||
"kg_overview": kg_overview,
|
||
"manifest_text": self._compose_manifest_text(
|
||
project_info=project_info,
|
||
module_counts=module_counts,
|
||
feature_summary=feature_summary,
|
||
symbol_snapshot=symbol_snapshot,
|
||
kg_overview=kg_overview,
|
||
),
|
||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
|
||
async def entity_resolution(
|
||
self,
|
||
*,
|
||
question: str,
|
||
project_id: Optional[int] = None,
|
||
limit: int = 10,
|
||
) -> dict[str, Any]:
|
||
normalized = self._normalize_entity_text(question)
|
||
alias_terms = self._collect_alias_terms(normalized)
|
||
query_terms = self._extract_query_terms(question, preferred=self._extract_project_lookup_keyword(question))
|
||
for term in alias_terms:
|
||
if term not in query_terms:
|
||
query_terms.append(term)
|
||
|
||
candidates: list[dict[str, Any]] = []
|
||
if query_terms:
|
||
try:
|
||
max_terms = query_terms[:6]
|
||
conditions = []
|
||
for term in max_terms:
|
||
like = f"%{term}%"
|
||
conditions.append(KGEntity.display_name.ilike(like))
|
||
conditions.append(KGEntity.entity_key.ilike(like))
|
||
rows = (
|
||
await self.db.execute(
|
||
select(KGEntity)
|
||
.where(
|
||
and_(
|
||
KGEntity.tenant_id == self.tenant_id,
|
||
or_(*conditions),
|
||
)
|
||
)
|
||
.order_by(KGEntity.last_seen_at.desc(), KGEntity.id.desc())
|
||
.limit(max(20, int(limit) * 4))
|
||
)
|
||
).scalars().all()
|
||
for row in rows:
|
||
payload = self._safe_dict(row.payload)
|
||
row_project_id = payload.get("project_id")
|
||
if project_id is not None and row_project_id is not None and int(row_project_id) != int(project_id):
|
||
continue
|
||
score = 0.0
|
||
lowered_name = str(row.display_name or "").lower()
|
||
lowered_key = str(row.entity_key or "").lower()
|
||
for term in max_terms:
|
||
term_lower = term.lower()
|
||
if term_lower in lowered_name:
|
||
score += 0.34
|
||
if term_lower in lowered_key:
|
||
score += 0.22
|
||
if score <= 0:
|
||
continue
|
||
candidates.append(
|
||
{
|
||
"entity_id": int(row.id),
|
||
"entity_type": str(row.entity_type or ""),
|
||
"entity_key": str(row.entity_key or ""),
|
||
"display_name": str(row.display_name or ""),
|
||
"score": round(min(score, 0.99), 4),
|
||
}
|
||
)
|
||
except Exception:
|
||
candidates = []
|
||
candidates.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True)
|
||
|
||
return {
|
||
"normalized_question": normalized,
|
||
"canonical_terms": alias_terms,
|
||
"query_terms": query_terms[:8],
|
||
"matched_entities": candidates[: max(1, int(limit))],
|
||
}
|
||
|
||
async def get_feature_catalog(
|
||
self,
|
||
*,
|
||
query: Optional[str] = None,
|
||
limit: int = 20,
|
||
) -> dict[str, Any]:
|
||
system_snapshot = await self.context_cache.get_system_snapshot(
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
)
|
||
catalog = self._feature_catalog_from_snapshot(system_snapshot)
|
||
if query and str(query).strip():
|
||
rows = self._match_feature_catalog_entries(
|
||
question=str(query),
|
||
system_snapshot=system_snapshot,
|
||
limit=max(1, min(int(limit), 100)),
|
||
)
|
||
return {
|
||
"query": str(query),
|
||
"count": len(rows),
|
||
"items": rows,
|
||
}
|
||
max_rows = max(1, min(int(limit), 200))
|
||
return {
|
||
"query": str(query or ""),
|
||
"count": min(len(catalog), max_rows),
|
||
"items": catalog[:max_rows],
|
||
}
|
||
|
||
def _compose_manifest_text(
|
||
self,
|
||
*,
|
||
project_info: dict[str, Any],
|
||
module_counts: dict[str, Any],
|
||
feature_summary: dict[str, Any],
|
||
symbol_snapshot: dict[str, Any],
|
||
kg_overview: dict[str, Any],
|
||
) -> str:
|
||
lines: list[str] = []
|
||
if project_info:
|
||
lines.append(
|
||
f"当前项目:#{int(project_info.get('id') or 0)} {str(project_info.get('title') or 'unknown')} "
|
||
f"(status={str(project_info.get('status') or 'unknown')})"
|
||
)
|
||
lines.append(
|
||
"后端模块规模:"
|
||
f"api={int(module_counts.get('api', 0))},"
|
||
f"services={int(module_counts.get('services', 0))},"
|
||
f"models={int(module_counts.get('models', 0))},"
|
||
f"schemas={int(module_counts.get('schemas', 0))}"
|
||
)
|
||
lines.append(f"产品功能:total={int(feature_summary.get('feature_total', 0))}")
|
||
top_features = feature_summary.get("top_features")
|
||
if isinstance(top_features, list) and top_features:
|
||
feature_brief = ";".join(
|
||
f"{str(item.get('name') or '')}({str(item.get('route') or '')})"
|
||
for item in top_features[:6]
|
||
if isinstance(item, dict) and str(item.get("name") or "").strip()
|
||
)
|
||
if feature_brief:
|
||
lines.append(f"核心入口:{feature_brief}")
|
||
lines.append(
|
||
"符号索引:"
|
||
f"files={int(symbol_snapshot.get('indexed_files', 0))},"
|
||
f"symbols={int(symbol_snapshot.get('symbol_total', 0))},"
|
||
f"python={int(symbol_snapshot.get('python_symbols', 0))},"
|
||
f"typescript={int(symbol_snapshot.get('typescript_symbols', 0))}"
|
||
)
|
||
lines.append(
|
||
"知识图谱:"
|
||
f"entities={int(kg_overview.get('entity_total', 0))},"
|
||
f"relations={int(kg_overview.get('relation_total', 0))},"
|
||
f"evidences={int(kg_overview.get('evidence_total', 0))}"
|
||
)
|
||
top_modules = symbol_snapshot.get("top_modules")
|
||
if isinstance(top_modules, list) and top_modules:
|
||
brief = ";".join(
|
||
f"{str(item.get('module') or '')}:{int(item.get('symbols') or 0)}"
|
||
for item in top_modules[:5]
|
||
if isinstance(item, dict)
|
||
)
|
||
if brief:
|
||
lines.append(f"热点模块:{brief}")
|
||
return "\n".join(lines)[:2200]
|
||
|
||
@staticmethod
|
||
def _normalize_entity_text(text: str) -> str:
|
||
normalized = str(text or "").strip().lower()
|
||
normalized = normalized.replace("—", "-").replace("_", " ")
|
||
normalized = re.sub(r"[^\w\u4e00-\u9fff\- ]+", " ", normalized)
|
||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||
return normalized
|
||
|
||
def _collect_alias_terms(self, normalized_question: str) -> list[str]:
|
||
aliases: list[str] = []
|
||
for token, canonical in ENTITY_ALIAS_MAP.items():
|
||
if token in normalized_question and canonical not in aliases:
|
||
aliases.append(canonical)
|
||
return aliases[:8]
|
||
|
||
@staticmethod
|
||
def _feature_catalog_from_snapshot(system_snapshot: Optional[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
if not isinstance(system_snapshot, dict):
|
||
return []
|
||
platform = system_snapshot.get("platform_profile")
|
||
if not isinstance(platform, dict):
|
||
return []
|
||
catalog = platform.get("feature_catalog")
|
||
if not isinstance(catalog, list):
|
||
return []
|
||
return [item for item in catalog if isinstance(item, dict)]
|
||
|
||
def _match_feature_catalog_entries(
|
||
self,
|
||
*,
|
||
question: str,
|
||
system_snapshot: Optional[dict[str, Any]],
|
||
limit: int = 4,
|
||
) -> list[dict[str, Any]]:
|
||
text = str(question or "").strip().lower()
|
||
if not text:
|
||
return []
|
||
catalog = self._feature_catalog_from_snapshot(system_snapshot)
|
||
if not catalog:
|
||
return []
|
||
query_terms = [x.lower() for x in self._extract_query_terms(question)[:8] if str(x).strip()]
|
||
matched: list[dict[str, Any]] = []
|
||
for item in catalog:
|
||
name = str(item.get("name") or "").strip()
|
||
route = str(item.get("route") or "").strip()
|
||
component = str(item.get("page_component") or "").strip()
|
||
aliases_raw = item.get("aliases")
|
||
alias_values = aliases_raw if isinstance(aliases_raw, list) else []
|
||
alias_pool = [name, route, component, *[str(x) for x in alias_values]]
|
||
alias_pool = [a.strip().lower() for a in alias_pool if str(a).strip()]
|
||
if not alias_pool:
|
||
continue
|
||
|
||
score = 0.0
|
||
for alias in alias_pool:
|
||
if alias and alias in text:
|
||
score += 0.45
|
||
for term in query_terms:
|
||
if term and any(term in alias or alias in term for alias in alias_pool):
|
||
score += 0.18
|
||
if score <= 0:
|
||
continue
|
||
matched.append(
|
||
{
|
||
**item,
|
||
"match_score": round(min(score, 0.99), 4),
|
||
}
|
||
)
|
||
|
||
matched.sort(key=lambda x: float(x.get("match_score") or 0.0), reverse=True)
|
||
return matched[: max(1, int(limit))]
|
||
|
||
def _retrieve_feature_catalog_context(
|
||
self,
|
||
*,
|
||
question: str,
|
||
system_snapshot: Optional[dict[str, Any]],
|
||
max_items: int = 4,
|
||
) -> list[RetrievedItem]:
|
||
rows = self._match_feature_catalog_entries(
|
||
question=question,
|
||
system_snapshot=system_snapshot,
|
||
limit=max_items,
|
||
)
|
||
out: list[RetrievedItem] = []
|
||
for row in rows:
|
||
name = str(row.get("name") or "unknown_feature")
|
||
route = str(row.get("route") or "")
|
||
summary = str(row.get("summary") or "")
|
||
page_file = str(row.get("page_file") or "")
|
||
page_component = str(row.get("page_component") or "")
|
||
apis = row.get("apis") if isinstance(row.get("apis"), list) else []
|
||
api_text = "、".join(str(x) for x in apis[:4] if str(x).strip())
|
||
content = f"功能: {name}\n用途: {summary}\n入口: {route}\n页面: {page_file}"
|
||
if api_text:
|
||
content += f"\n相关接口: {api_text}"
|
||
score = min(0.99, max(0.62, float(row.get("match_score") or 0.62)))
|
||
out.append(
|
||
RetrievedItem(
|
||
source="feature_catalog",
|
||
content=content[:1200],
|
||
score=score,
|
||
source_label="Feature Catalog",
|
||
metadata={
|
||
"feature_name": name,
|
||
"route": route,
|
||
"page_file": page_file,
|
||
"page_component": page_component,
|
||
"apis": [str(x) for x in apis[:8] if str(x).strip()],
|
||
"match_score": round(score, 4),
|
||
},
|
||
)
|
||
)
|
||
return out
|
||
|
||
def _collect_feature_evidence(
|
||
self,
|
||
*,
|
||
question: str,
|
||
retrieved: list[RetrievedItem],
|
||
system_snapshot: Optional[dict[str, Any]],
|
||
) -> list[dict[str, Any]]:
|
||
rows: list[dict[str, Any]] = []
|
||
for item in retrieved:
|
||
if str(item.source or "").strip().lower() != "feature_catalog":
|
||
continue
|
||
meta = item.metadata if isinstance(item.metadata, dict) else {}
|
||
route = str(meta.get("route") or "").strip()
|
||
page_file = str(meta.get("page_file") or "").strip()
|
||
feature_name = str(meta.get("feature_name") or "").strip()
|
||
apis = meta.get("apis") if isinstance(meta.get("apis"), list) else []
|
||
clean_apis = [str(x).strip() for x in apis if str(x).strip()]
|
||
if not feature_name and not route and not page_file:
|
||
continue
|
||
rows.append(
|
||
{
|
||
"feature_name": feature_name,
|
||
"route": route,
|
||
"page_file": page_file,
|
||
"apis": clean_apis[:6],
|
||
}
|
||
)
|
||
if rows:
|
||
return rows[:4]
|
||
|
||
# 若检索结果里没有 feature_catalog,回退到 snapshot 匹配结果
|
||
fallback = self._match_feature_catalog_entries(
|
||
question=question,
|
||
system_snapshot=system_snapshot,
|
||
limit=3,
|
||
)
|
||
for row in fallback:
|
||
apis = row.get("apis") if isinstance(row.get("apis"), list) else []
|
||
rows.append(
|
||
{
|
||
"feature_name": str(row.get("name") or ""),
|
||
"route": str(row.get("route") or ""),
|
||
"page_file": str(row.get("page_file") or ""),
|
||
"apis": [str(x).strip() for x in apis[:6] if str(x).strip()],
|
||
}
|
||
)
|
||
return rows[:3]
|
||
|
||
def _enforce_feature_evidence_in_answer(
|
||
self,
|
||
*,
|
||
question: str,
|
||
answer_text: str,
|
||
retrieved: list[RetrievedItem],
|
||
system_snapshot: Optional[dict[str, Any]],
|
||
) -> str:
|
||
text = str(question or "").strip().lower()
|
||
has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS)
|
||
ask_feature = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS)
|
||
if not ask_feature:
|
||
return answer_text
|
||
|
||
evidence_rows = self._collect_feature_evidence(
|
||
question=question,
|
||
retrieved=retrieved,
|
||
system_snapshot=system_snapshot,
|
||
)
|
||
if not evidence_rows:
|
||
return answer_text
|
||
|
||
# 已包含明确入口与接口信息时不重复追加
|
||
lowered_answer = str(answer_text or "").lower()
|
||
if ("/" in lowered_answer and "接口" in lowered_answer) or "证据补充" in lowered_answer:
|
||
return answer_text
|
||
|
||
lines: list[str] = [str(answer_text or "").strip(), "", "证据补充(Feature Catalog):"]
|
||
for idx, row in enumerate(evidence_rows, start=1):
|
||
feature_name = str(row.get("feature_name") or "未知功能")
|
||
route = str(row.get("route") or "")
|
||
page_file = str(row.get("page_file") or "")
|
||
apis = row.get("apis") if isinstance(row.get("apis"), list) else []
|
||
api_text = "、".join(str(x) for x in apis[:3] if str(x).strip())
|
||
line = f"{idx}. {feature_name}"
|
||
if route:
|
||
line += f" | 路由: {route}"
|
||
if page_file:
|
||
line += f" | 页面: {page_file}"
|
||
if api_text:
|
||
line += f" | API: {api_text}"
|
||
lines.append(line)
|
||
return "\n".join(lines).strip()[:3200]
|
||
|
||
def _symbol_cache_key(self) -> str:
|
||
return f"{self.tenant_id}:backend_symbol_snapshot"
|
||
|
||
async def _get_symbol_snapshot_cached(self, *, ttl_seconds: int = 300) -> dict[str, Any]:
|
||
now = time.time()
|
||
key = self._symbol_cache_key()
|
||
cached = self._symbol_snapshot_cache.get(key)
|
||
if cached and cached[0] > now:
|
||
return dict(cached[1])
|
||
try:
|
||
service = SymbolIndexService(self.db, tenant_id=self.tenant_id)
|
||
built = await service.get_overview(top_n_files=10, auto_sync_if_empty=True)
|
||
except Exception:
|
||
built = self._build_symbol_index_snapshot()
|
||
self._symbol_snapshot_cache[key] = (now + max(60, int(ttl_seconds)), dict(built))
|
||
return built
|
||
|
||
def _build_symbol_index_snapshot(
|
||
self,
|
||
*,
|
||
max_files: int = 240,
|
||
max_symbols: int = 4000,
|
||
) -> dict[str, Any]:
|
||
backend_root = Path(__file__).resolve().parents[2]
|
||
app_root = backend_root / "app"
|
||
if not app_root.exists():
|
||
return {
|
||
"root": str(app_root),
|
||
"indexed_files": 0,
|
||
"symbol_total": 0,
|
||
"python_symbols": 0,
|
||
"typescript_symbols": 0,
|
||
"top_modules": [],
|
||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
|
||
candidates = [
|
||
p
|
||
for p in app_root.rglob("*")
|
||
if p.is_file() and p.suffix.lower() in {".py", ".ts", ".tsx", ".js", ".jsx"}
|
||
]
|
||
candidates.sort(key=lambda p: str(p))
|
||
file_paths = candidates[: max(1, int(max_files))]
|
||
|
||
python_symbols = 0
|
||
ts_symbols = 0
|
||
total_symbols = 0
|
||
module_counter: dict[str, int] = {}
|
||
|
||
for path in file_paths:
|
||
rel = str(path.relative_to(backend_root))
|
||
module = rel.split("/", 2)[:2]
|
||
module_name = "/".join(module) if module else rel
|
||
try:
|
||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||
except Exception:
|
||
continue
|
||
|
||
symbols_in_file = 0
|
||
if path.suffix.lower() == ".py":
|
||
extracted = self._extract_python_symbols(text)
|
||
symbols_in_file = len(extracted)
|
||
python_symbols += symbols_in_file
|
||
else:
|
||
extracted = self._extract_text_symbols(text)
|
||
symbols_in_file = len(extracted)
|
||
ts_symbols += symbols_in_file
|
||
if symbols_in_file <= 0:
|
||
continue
|
||
module_counter[module_name] = int(module_counter.get(module_name, 0)) + symbols_in_file
|
||
total_symbols += symbols_in_file
|
||
if total_symbols >= max_symbols:
|
||
break
|
||
|
||
top_modules = [
|
||
{"module": name, "symbols": int(count)}
|
||
for name, count in sorted(module_counter.items(), key=lambda item: item[1], reverse=True)[:10]
|
||
]
|
||
return {
|
||
"root": str(app_root),
|
||
"indexed_files": len(file_paths),
|
||
"symbol_total": int(total_symbols),
|
||
"python_symbols": int(python_symbols),
|
||
"typescript_symbols": int(ts_symbols),
|
||
"top_modules": top_modules,
|
||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
|
||
@staticmethod
|
||
def _extract_python_symbols(text: str) -> list[str]:
|
||
raw = str(text or "")
|
||
if not raw.strip():
|
||
return []
|
||
try:
|
||
tree = ast.parse(raw)
|
||
except Exception:
|
||
return []
|
||
out: list[str] = []
|
||
for node in tree.body:
|
||
if isinstance(node, ast.ClassDef):
|
||
out.append(f"class:{node.name}")
|
||
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
out.append(f"function:{node.name}")
|
||
return out
|
||
|
||
@staticmethod
|
||
def _extract_text_symbols(text: str) -> list[str]:
|
||
raw = str(text or "")
|
||
if not raw.strip():
|
||
return []
|
||
out: list[str] = []
|
||
for match in TS_SYMBOL_RE.finditer(raw):
|
||
name = str(match.group(1) or "").strip()
|
||
if name:
|
||
out.append(name)
|
||
return out
|
||
|
||
async def _run_single_retrieval_pass(
|
||
self,
|
||
*,
|
||
question: str,
|
||
domains: list[str],
|
||
project_id: Optional[int],
|
||
source_chat_id: Optional[str],
|
||
resolved_entities: Optional[dict[str, Any]] = None,
|
||
vector_top_k: Optional[int] = None,
|
||
) -> tuple[list[RetrievedItem], dict[str, Any]]:
|
||
async def _run_db_retrieval() -> tuple[list[RetrievedItem], int, bool]:
|
||
if "db" not in domains:
|
||
return ([], 0, False)
|
||
started_at = time.perf_counter()
|
||
try:
|
||
rows = await asyncio.wait_for(
|
||
self._retrieve_structured_db(question, project_id=project_id),
|
||
timeout=DB_QUERY_TIMEOUT_SECONDS,
|
||
)
|
||
return (rows, int((time.perf_counter() - started_at) * 1000), False)
|
||
except asyncio.TimeoutError:
|
||
return ([], int((time.perf_counter() - started_at) * 1000), True)
|
||
|
||
async def _run_vector_retrieval() -> tuple[list[RetrievedItem], int, bool]:
|
||
if not any(x in domains for x in {"doc", "feishu", "pmflow", "url"}):
|
||
return ([], 0, False)
|
||
started_at = time.perf_counter()
|
||
try:
|
||
rows = await asyncio.wait_for(
|
||
self._retrieve_vector_context(
|
||
question,
|
||
domains=domains,
|
||
project_id=project_id,
|
||
source_chat_id=source_chat_id,
|
||
top_k=vector_top_k,
|
||
),
|
||
timeout=VECTOR_SEARCH_TIMEOUT_SECONDS,
|
||
)
|
||
return (rows, int((time.perf_counter() - started_at) * 1000), False)
|
||
except asyncio.TimeoutError:
|
||
return ([], int((time.perf_counter() - started_at) * 1000), True)
|
||
|
||
async def _run_graph_retrieval() -> tuple[list[RetrievedItem], int, bool]:
|
||
if "graph" not in domains:
|
||
return ([], 0, False)
|
||
started_at = time.perf_counter()
|
||
try:
|
||
rows = await asyncio.wait_for(
|
||
self._retrieve_graph_context(
|
||
question,
|
||
project_id=project_id,
|
||
resolved_entities=resolved_entities,
|
||
),
|
||
timeout=GRAPH_QUERY_TIMEOUT_SECONDS,
|
||
)
|
||
return (rows, int((time.perf_counter() - started_at) * 1000), False)
|
||
except asyncio.TimeoutError:
|
||
return ([], int((time.perf_counter() - started_at) * 1000), True)
|
||
|
||
(db_rows, db_ms, db_timeout), (vec_rows, vec_ms, vec_timeout), (graph_rows, graph_ms, graph_timeout) = (
|
||
await asyncio.gather(
|
||
_run_db_retrieval(),
|
||
_run_vector_retrieval(),
|
||
_run_graph_retrieval(),
|
||
)
|
||
)
|
||
merged = [*db_rows, *vec_rows, *graph_rows]
|
||
merged.sort(key=lambda x: x.score, reverse=True)
|
||
return (
|
||
merged,
|
||
{
|
||
"db_query_ms": int(db_ms),
|
||
"vector_search_ms": int(vec_ms),
|
||
"graph_query_ms": int(graph_ms),
|
||
"db_timeout": bool(db_timeout),
|
||
"vector_timeout": bool(vec_timeout),
|
||
"graph_timeout": bool(graph_timeout),
|
||
},
|
||
)
|
||
|
||
async def _build_second_pass_query(
|
||
self,
|
||
*,
|
||
question: str,
|
||
project_id: Optional[int],
|
||
) -> tuple[str, dict[str, Any]]:
|
||
resolved = await self.entity_resolution(question=question, project_id=project_id, limit=5)
|
||
manifest = await self.get_project_manifest(project_id=project_id)
|
||
hints: list[str] = []
|
||
for term in resolved.get("canonical_terms") or []:
|
||
if str(term).strip():
|
||
hints.append(str(term).strip())
|
||
for row in resolved.get("matched_entities") or []:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
name = str(row.get("display_name") or "").strip()
|
||
if name:
|
||
hints.append(name[:30])
|
||
|
||
symbol_names: list[str] = []
|
||
try:
|
||
symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id)
|
||
symbol_hits = await symbol_service.search_symbols(query=question, limit=8)
|
||
for row in symbol_hits.get("items") or []:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
name = str(row.get("qualified_name") or row.get("symbol_name") or "").strip()
|
||
if name:
|
||
symbol_names.append(name[:60])
|
||
except Exception:
|
||
symbol_names = []
|
||
for name in symbol_names[:6]:
|
||
hints.append(name)
|
||
|
||
feature_names: list[str] = []
|
||
feature_routes: list[str] = []
|
||
try:
|
||
system_snapshot = await self.context_cache.get_system_snapshot(
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
)
|
||
feature_hits = self._match_feature_catalog_entries(
|
||
question=question,
|
||
system_snapshot=system_snapshot,
|
||
limit=6,
|
||
)
|
||
for row in feature_hits:
|
||
name = str(row.get("name") or "").strip()
|
||
route = str(row.get("route") or "").strip()
|
||
if name:
|
||
feature_names.append(name[:50])
|
||
if route:
|
||
feature_routes.append(route[:80])
|
||
except Exception:
|
||
feature_names = []
|
||
feature_routes = []
|
||
for name in feature_names[:4]:
|
||
hints.append(name)
|
||
for route in feature_routes[:3]:
|
||
hints.append(route)
|
||
|
||
symbol_top = manifest.get("symbol_summary") if isinstance(manifest.get("symbol_summary"), dict) else {}
|
||
modules = symbol_top.get("top_modules") if isinstance(symbol_top.get("top_modules"), list) else []
|
||
module_hint = ";".join(
|
||
str(item.get("module") or "")
|
||
for item in modules[:3]
|
||
if isinstance(item, dict) and str(item.get("module") or "").strip()
|
||
)
|
||
clean_hints = []
|
||
for item in hints:
|
||
if item and item not in clean_hints:
|
||
clean_hints.append(item)
|
||
if not clean_hints and not module_hint:
|
||
return question[:1200], {"mode": "no_hint"}
|
||
|
||
revised = str(question or "").strip()
|
||
if clean_hints:
|
||
revised = f"{revised}。实体线索:{', '.join(clean_hints[:6])}"
|
||
if module_hint:
|
||
revised = f"{revised}。候选模块:{module_hint}"
|
||
if symbol_names:
|
||
revised = f"{revised}。候选符号:{', '.join(symbol_names[:4])}"
|
||
if feature_names:
|
||
revised = f"{revised}。候选功能:{', '.join(feature_names[:3])}"
|
||
if feature_routes:
|
||
revised = f"{revised}。候选入口:{', '.join(feature_routes[:2])}"
|
||
return revised[:1200], {
|
||
"mode": "second_pass_query",
|
||
"hint_count": len(clean_hints),
|
||
"module_hint": module_hint,
|
||
"symbol_hit_count": len(symbol_names),
|
||
"feature_hit_count": len(feature_names),
|
||
}
|
||
|
||
@staticmethod
|
||
def _merge_retrieved_items(
|
||
base: list[RetrievedItem],
|
||
extra: list[RetrievedItem],
|
||
*,
|
||
limit: Optional[int] = None,
|
||
) -> list[RetrievedItem]:
|
||
seen: set[str] = set()
|
||
merged: list[RetrievedItem] = []
|
||
for item in [*base, *extra]:
|
||
key = f"{item.source}|{item.source_label}|{item.content[:120]}"
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
merged.append(item)
|
||
merged.sort(key=lambda x: x.score, reverse=True)
|
||
cap = max(DEFAULT_TOP_K * 2, 8)
|
||
if limit is not None:
|
||
cap = max(4, min(int(limit), 80))
|
||
return merged[:cap]
|
||
|
||
@staticmethod
|
||
def _should_second_pass(retrieved: list[RetrievedItem]) -> bool:
|
||
if not retrieved:
|
||
return True
|
||
top_score = float(retrieved[0].score or 0.0)
|
||
if top_score < 0.55:
|
||
return True
|
||
return len(retrieved) < 3
|
||
|
||
@staticmethod
|
||
def _merge_search_metrics(base: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"db_query_ms": int(base.get("db_query_ms", 0)) + int(delta.get("db_query_ms", 0)),
|
||
"vector_search_ms": int(base.get("vector_search_ms", 0)) + int(delta.get("vector_search_ms", 0)),
|
||
"graph_query_ms": int(base.get("graph_query_ms", 0)) + int(delta.get("graph_query_ms", 0)),
|
||
"db_timeout": bool(base.get("db_timeout")) or bool(delta.get("db_timeout")),
|
||
"vector_timeout": bool(base.get("vector_timeout")) or bool(delta.get("vector_timeout")),
|
||
"graph_timeout": bool(base.get("graph_timeout")) or bool(delta.get("graph_timeout")),
|
||
}
|
||
|
||
@staticmethod
|
||
def _summarize_retrieval_hints(rows: list[RetrievedItem], *, max_items: int = 3) -> str:
|
||
hints: list[str] = []
|
||
for row in rows[: max(1, int(max_items))]:
|
||
label = str(row.source_label or "").strip()
|
||
if label:
|
||
hints.append(label[:60])
|
||
continue
|
||
content = str(row.content or "").strip()
|
||
if content:
|
||
hints.append(content.replace("\n", " ")[:60])
|
||
uniq: list[str] = []
|
||
for item in hints:
|
||
if item and item not in uniq:
|
||
uniq.append(item)
|
||
return ";".join(uniq[:3])
|
||
|
||
async def _multi_step_search(
|
||
self,
|
||
*,
|
||
question: str,
|
||
domains: list[str],
|
||
project_id: Optional[int],
|
||
source_chat_id: Optional[str],
|
||
session_state: Optional[QASessionState],
|
||
max_steps_override: Optional[int] = None,
|
||
vector_top_k: Optional[int] = None,
|
||
) -> tuple[list[RetrievedItem], dict[str, Any]]:
|
||
resolved = await self.entity_resolution(question=question, project_id=project_id, limit=6)
|
||
manifest = await self.get_project_manifest(project_id=project_id)
|
||
system_snapshot = await self.context_cache.get_system_snapshot(
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
)
|
||
symbol_hits: dict[str, Any] = {"count": 0, "items": []}
|
||
try:
|
||
symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id)
|
||
symbol_hits = await symbol_service.search_symbols(query=question, limit=6)
|
||
except Exception:
|
||
symbol_hits = {"count": 0, "items": []}
|
||
feature_hits = self._match_feature_catalog_entries(
|
||
question=question,
|
||
system_snapshot=system_snapshot,
|
||
limit=6,
|
||
)
|
||
max_steps = max(1, min(int(max_steps_override or AGENT_MAX_RETRIEVAL_STEPS), 8))
|
||
tool_trace: list[dict[str, Any]] = [
|
||
{
|
||
"step": 0,
|
||
"tool": "entity_resolution",
|
||
"matched_entities": len(resolved.get("matched_entities") or []),
|
||
"canonical_terms": resolved.get("canonical_terms") if isinstance(resolved.get("canonical_terms"), list) else [],
|
||
},
|
||
{
|
||
"step": 0,
|
||
"tool": "get_project_manifest",
|
||
"symbol_total": int(
|
||
(
|
||
manifest.get("symbol_summary")
|
||
if isinstance(manifest.get("symbol_summary"), dict)
|
||
else {}
|
||
).get("symbol_total", 0)
|
||
),
|
||
"kg_entity_total": int(
|
||
(
|
||
manifest.get("kg_overview")
|
||
if isinstance(manifest.get("kg_overview"), dict)
|
||
else {}
|
||
).get("entity_total", 0)
|
||
),
|
||
},
|
||
{
|
||
"step": 0,
|
||
"tool": "search_symbols",
|
||
"hit_count": int(symbol_hits.get("count") or 0),
|
||
},
|
||
{
|
||
"step": 0,
|
||
"tool": "feature_catalog_lookup",
|
||
"hit_count": len(feature_hits),
|
||
"feature_names": [str(x.get("name") or "") for x in feature_hits[:4]],
|
||
},
|
||
]
|
||
if session_state is not None and isinstance(session_state.last_entity_ids, list):
|
||
anchor_ids = [int(x) for x in session_state.last_entity_ids if str(x).isdigit()][:6]
|
||
else:
|
||
anchor_ids = []
|
||
current_question = str(question or "").strip()[:1200]
|
||
current_domains: list[str] = []
|
||
for item in domains:
|
||
if item not in current_domains:
|
||
current_domains.append(item)
|
||
merged_rows: list[RetrievedItem] = []
|
||
merged_metrics: dict[str, Any] = {
|
||
"db_query_ms": 0,
|
||
"vector_search_ms": 0,
|
||
"graph_query_ms": 0,
|
||
"db_timeout": False,
|
||
"vector_timeout": False,
|
||
"graph_timeout": False,
|
||
}
|
||
merge_limit = max(DEFAULT_TOP_K * 2, 8)
|
||
if vector_top_k is not None:
|
||
merge_limit = max(merge_limit, max(2, int(vector_top_k)) * 3)
|
||
|
||
for step in range(1, max_steps + 1):
|
||
resolved_payload = {
|
||
**resolved,
|
||
"anchor_entity_ids": anchor_ids,
|
||
}
|
||
step_rows, step_metrics = await self._run_single_retrieval_pass(
|
||
question=current_question,
|
||
domains=current_domains,
|
||
project_id=project_id,
|
||
source_chat_id=source_chat_id,
|
||
resolved_entities=resolved_payload,
|
||
vector_top_k=vector_top_k,
|
||
)
|
||
feature_rows = self._retrieve_feature_catalog_context(
|
||
question=current_question,
|
||
system_snapshot=system_snapshot,
|
||
max_items=4,
|
||
)
|
||
if feature_rows:
|
||
step_rows = self._merge_retrieved_items(feature_rows, step_rows, limit=merge_limit)
|
||
merged_rows = self._merge_retrieved_items(merged_rows, step_rows, limit=merge_limit)
|
||
merged_metrics = self._merge_search_metrics(merged_metrics, step_metrics)
|
||
top_score = round(float(step_rows[0].score), 4) if step_rows else 0.0
|
||
tool_trace.append(
|
||
{
|
||
"step": step,
|
||
"tool": "parallel_retrieval",
|
||
"query": current_question[:240],
|
||
"domains": current_domains,
|
||
"items": len(step_rows),
|
||
"feature_items": len(feature_rows),
|
||
"top_score": top_score,
|
||
"merged_items": len(merged_rows),
|
||
}
|
||
)
|
||
|
||
if step >= max_steps:
|
||
tool_trace.append(
|
||
{
|
||
"step": step,
|
||
"tool": "planner_stop",
|
||
"reason": "max_steps_reached",
|
||
"max_steps": max_steps,
|
||
}
|
||
)
|
||
break
|
||
if not self._should_second_pass(merged_rows):
|
||
tool_trace.append(
|
||
{
|
||
"step": step,
|
||
"tool": "planner_stop",
|
||
"reason": "enough_evidence",
|
||
"top_score": round(float(merged_rows[0].score), 4) if merged_rows else 0.0,
|
||
}
|
||
)
|
||
break
|
||
|
||
next_question, rewrite_meta = await self._build_second_pass_query(
|
||
question=current_question,
|
||
project_id=project_id,
|
||
)
|
||
row_hint = self._summarize_retrieval_hints(step_rows)
|
||
if row_hint and row_hint not in next_question:
|
||
next_question = f"{next_question}。已命中线索:{row_hint}"
|
||
next_question = next_question[:1200]
|
||
if next_question.strip() == current_question.strip():
|
||
tool_trace.append(
|
||
{
|
||
"step": step,
|
||
"tool": "planner_stop",
|
||
"reason": "query_not_changed",
|
||
"rewrite_meta": rewrite_meta,
|
||
}
|
||
)
|
||
break
|
||
|
||
next_domains: list[str] = []
|
||
for item in [*current_domains, "graph", "db", "doc", "pmflow", "feishu"]:
|
||
if item not in next_domains:
|
||
next_domains.append(item)
|
||
tool_trace.append(
|
||
{
|
||
"step": step + 1,
|
||
"tool": "query_refine",
|
||
"rewrite_meta": rewrite_meta,
|
||
"next_query": next_question[:240],
|
||
"next_domains": next_domains,
|
||
"anchor_entity_ids": anchor_ids,
|
||
}
|
||
)
|
||
current_question = next_question
|
||
current_domains = next_domains
|
||
|
||
result_cap = max(DEFAULT_TOP_K * 2, 8)
|
||
if vector_top_k is not None:
|
||
result_cap = max(result_cap, max(2, int(vector_top_k)) * 2)
|
||
|
||
return merged_rows[:result_cap], {
|
||
**merged_metrics,
|
||
"domains_used": current_domains,
|
||
"tool_trace": tool_trace,
|
||
}
|
||
|
||
async def multi_step_search(
|
||
self,
|
||
*,
|
||
question: str,
|
||
project_id: Optional[int] = None,
|
||
source_chat_id: Optional[str] = None,
|
||
session_state: Optional[QASessionState] = None,
|
||
) -> dict[str, Any]:
|
||
domains = self._route_domains(question)
|
||
rows, metrics = await self._multi_step_search(
|
||
question=question,
|
||
domains=domains,
|
||
project_id=project_id,
|
||
source_chat_id=source_chat_id,
|
||
session_state=session_state,
|
||
)
|
||
return {
|
||
"question": question,
|
||
"domains": metrics.get("domains_used") or domains,
|
||
"items": self._dedupe_sources(rows),
|
||
"retrieval_metrics": {
|
||
"db_query_ms": int(metrics.get("db_query_ms") or 0),
|
||
"vector_search_ms": int(metrics.get("vector_search_ms") or 0),
|
||
"graph_query_ms": int(metrics.get("graph_query_ms") or 0),
|
||
"db_timeout": bool(metrics.get("db_timeout")),
|
||
"vector_timeout": bool(metrics.get("vector_timeout")),
|
||
"graph_timeout": bool(metrics.get("graph_timeout")),
|
||
},
|
||
"tool_trace": metrics.get("tool_trace") if isinstance(metrics.get("tool_trace"), list) else [],
|
||
}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Retrieval
|
||
# ------------------------------------------------------------------
|
||
def _route_domains(self, question: str) -> list[str]:
|
||
text = question.lower()
|
||
domains: list[str] = []
|
||
is_referential = any(k in text for k in FOLLOWUP_REFERENTIAL_KEYWORDS)
|
||
|
||
if self._is_people_query(text):
|
||
domains.append("db")
|
||
if any(k in text for k in PROJECT_KEYWORDS):
|
||
domains.append("db")
|
||
domains.append("graph")
|
||
if is_referential:
|
||
domains.append("graph")
|
||
if any(k in text for k in CODE_KEYWORDS):
|
||
domains.append("graph")
|
||
domains.append("doc")
|
||
if any(k in text for k in FEATURE_QUERY_KEYWORDS):
|
||
domains.append("doc")
|
||
domains.append("graph")
|
||
domains.append("pmflow")
|
||
if any(k in text for k in DOC_KEYWORDS):
|
||
domains.append("doc")
|
||
if any(k in text for k in HISTORY_KEYWORDS):
|
||
domains.append("feishu")
|
||
if any(k in text for k in PMFLOW_KEYWORDS):
|
||
domains.append("pmflow")
|
||
if self._is_engine_process_query(text):
|
||
domains.append("graph")
|
||
domains.append("doc")
|
||
domains.append("pmflow")
|
||
domains.append("db")
|
||
if URL_RE.search(text) or "根据" in text and "http" in text:
|
||
domains.append("url")
|
||
|
||
if not domains:
|
||
# 模糊问题:并行检索所有向量域
|
||
domains.extend(["graph", "doc", "feishu", "pmflow", "url"])
|
||
|
||
# 去重并保持顺序
|
||
uniq: list[str] = []
|
||
for item in domains:
|
||
if item not in uniq:
|
||
uniq.append(item)
|
||
return uniq
|
||
|
||
async def _retrieve_structured_db(self, question: str, *, project_id: Optional[int]) -> list[RetrievedItem]:
|
||
text = question.lower()
|
||
rows: list[RetrievedItem] = []
|
||
ask_people = self._is_people_query(text)
|
||
|
||
if ask_people:
|
||
scoped_project_id: Optional[int] = None
|
||
scoped_project_name: Optional[str] = None
|
||
if "项目" in text or project_id is not None:
|
||
scoped_project_id, scoped_project_name, _ = await self._resolve_project_scope_for_question(
|
||
question=text,
|
||
project_id=project_id,
|
||
)
|
||
user_stmt = select(User).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.is_active.is_(True),
|
||
)
|
||
)
|
||
if scoped_project_id is not None:
|
||
member_subq = select(UserProject.user_id).where(
|
||
and_(
|
||
UserProject.tenant_id == self.tenant_id,
|
||
UserProject.project_id == int(scoped_project_id),
|
||
)
|
||
)
|
||
user_stmt = user_stmt.where(User.id.in_(member_subq))
|
||
members = (
|
||
await self.db.execute(
|
||
user_stmt.order_by(User.updated_at.desc(), User.id.desc()).limit(20)
|
||
)
|
||
).scalars().all()
|
||
if members:
|
||
preview = ";".join(
|
||
f"#{int(row.id)} {str(row.name or f'User#{int(row.id)}')}(角色:{str(row.role or 'unknown')})"
|
||
for row in members[:10]
|
||
)
|
||
rows.append(
|
||
RetrievedItem(
|
||
source="db",
|
||
content=(
|
||
f"成员清单({len(members)}人):{preview}"
|
||
if scoped_project_name is None
|
||
else f"项目「{scoped_project_name}」成员清单({len(members)}人):{preview}"
|
||
),
|
||
score=0.95,
|
||
source_label="User database",
|
||
metadata={
|
||
"table": "users",
|
||
"count": len(members),
|
||
"project_id": scoped_project_id,
|
||
"project_name": scoped_project_name,
|
||
},
|
||
)
|
||
)
|
||
|
||
if "项目" in text and not ask_people:
|
||
stmt = select(Project).where(Project.tenant_id == self.tenant_id)
|
||
if project_id:
|
||
stmt = stmt.where(Project.id == project_id)
|
||
projects = (await self.db.execute(stmt.limit(8))).scalars().all()
|
||
if projects:
|
||
summary = ";".join(
|
||
f"#{p.id} {p.title or p.name}(状态:{p.status})" for p in projects
|
||
)
|
||
rows.append(
|
||
RetrievedItem(
|
||
source="db",
|
||
content=f"项目列表:{summary}",
|
||
score=0.93,
|
||
source_label="Project database",
|
||
metadata={"table": "projects", "count": len(projects)},
|
||
)
|
||
)
|
||
|
||
if "信号" in text or "待处理" in text:
|
||
signal_stmt = select(Signal).where(Signal.tenant_id == self.tenant_id)
|
||
if project_id:
|
||
signal_stmt = signal_stmt.where(
|
||
or_(
|
||
Signal.matched_project_id == project_id,
|
||
Signal.content.ilike(f"%#{project_id}%"),
|
||
)
|
||
)
|
||
pending = (
|
||
await self.db.execute(signal_stmt.order_by(Signal.created_at.desc()).limit(10))
|
||
).scalars().all()
|
||
if pending:
|
||
preview = ";".join((s.summary or s.content or "")[:80] for s in pending[:5])
|
||
rows.append(
|
||
RetrievedItem(
|
||
source="db",
|
||
content=f"最近信号({len(pending)}条):{preview}",
|
||
score=0.9,
|
||
source_label="Project database",
|
||
metadata={"table": "signals", "count": len(pending)},
|
||
)
|
||
)
|
||
|
||
if "任务" in text or "进度" in text:
|
||
task_stmt = select(Task).where(Task.tenant_id == self.tenant_id)
|
||
if project_id:
|
||
task_stmt = task_stmt.where(Task.project_id == project_id)
|
||
tasks = (await self.db.execute(task_stmt.order_by(Task.updated_at.desc()).limit(12))).scalars().all()
|
||
if tasks:
|
||
open_tasks = [t for t in tasks if str(t.status or "").lower() not in {"done", "completed", "已完成"}]
|
||
owners = [str(t.assignee_id) for t in open_tasks if t.assignee_id is not None]
|
||
rows.append(
|
||
RetrievedItem(
|
||
source="db",
|
||
content=(
|
||
f"任务总数 {len(tasks)},未完成 {len(open_tasks)}。"
|
||
f"当前负责人ID(去重): {', '.join(sorted(set(owners))[:8]) or '暂无'}"
|
||
),
|
||
score=0.91,
|
||
source_label="Project database",
|
||
metadata={"table": "tasks", "open_count": len(open_tasks)},
|
||
)
|
||
)
|
||
|
||
if "宪章" in text or "charter" in text:
|
||
charter_stmt = select(TruthNode).where(TruthNode.tenant_id == self.tenant_id)
|
||
if project_id:
|
||
charter_stmt = charter_stmt.where(TruthNode.project_id == project_id)
|
||
charters = (await self.db.execute(charter_stmt.order_by(TruthNode.updated_at.desc()).limit(6))).scalars().all()
|
||
if charters:
|
||
rows.append(
|
||
RetrievedItem(
|
||
source="db",
|
||
content="\n".join(
|
||
f"宪章#{c.id}: {(c.title or '无标题')} / {str(c.content or '')[:200]}" for c in charters
|
||
),
|
||
score=0.92,
|
||
source_label="Project database",
|
||
metadata={"table": "truth_nodes", "count": len(charters)},
|
||
)
|
||
)
|
||
|
||
if "工作量" in text or "负载" in text:
|
||
workload_stmt = select(UserWorkload).where(UserWorkload.tenant_id == self.tenant_id)
|
||
workloads = (await self.db.execute(workload_stmt.order_by(UserWorkload.updated_at.desc()).limit(10))).scalars().all()
|
||
if workloads:
|
||
content = ";".join(
|
||
f"user#{w.user_id} 已分配 {float(w.current_assigned_hours or 0):.1f}h / "
|
||
f"最大 {float(w.max_available_hours_per_week or 0):.1f}h,负载比 {float(w.workload_ratio or 0):.2f}"
|
||
for w in workloads
|
||
)
|
||
rows.append(
|
||
RetrievedItem(
|
||
source="db",
|
||
content=f"团队工作量:{content}",
|
||
score=0.88,
|
||
source_label="Project database",
|
||
metadata={"table": "user_workloads", "count": len(workloads)},
|
||
)
|
||
)
|
||
|
||
return rows[:DEFAULT_TOP_K]
|
||
|
||
async def _retrieve_graph_context(
|
||
self,
|
||
question: str,
|
||
*,
|
||
project_id: Optional[int],
|
||
resolved_entities: Optional[dict[str, Any]] = None,
|
||
) -> list[RetrievedItem]:
|
||
try:
|
||
resolution = resolved_entities or {}
|
||
matched_entities = (
|
||
resolution.get("matched_entities") if isinstance(resolution.get("matched_entities"), list) else []
|
||
)
|
||
anchor_ids_raw = (
|
||
resolution.get("anchor_entity_ids") if isinstance(resolution.get("anchor_entity_ids"), list) else []
|
||
)
|
||
anchor_ids: list[int] = []
|
||
for value in anchor_ids_raw:
|
||
try:
|
||
anchor_ids.append(int(value))
|
||
except Exception:
|
||
continue
|
||
|
||
seed_entities: list[KGEntity] = []
|
||
if matched_entities:
|
||
ids: list[int] = []
|
||
for row in matched_entities:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
try:
|
||
ids.append(int(row.get("entity_id")))
|
||
except Exception:
|
||
continue
|
||
if ids:
|
||
seed_entities = (
|
||
await self.db.execute(
|
||
select(KGEntity).where(
|
||
and_(
|
||
KGEntity.tenant_id == self.tenant_id,
|
||
KGEntity.id.in_(ids),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
|
||
if not seed_entities:
|
||
terms = self._extract_query_terms(question, preferred=self._extract_project_lookup_keyword(question))
|
||
if terms:
|
||
conds = []
|
||
for term in terms[:6]:
|
||
like = f"%{term}%"
|
||
conds.append(KGEntity.display_name.ilike(like))
|
||
conds.append(KGEntity.entity_key.ilike(like))
|
||
seed_entities = (
|
||
await self.db.execute(
|
||
select(KGEntity)
|
||
.where(
|
||
and_(
|
||
KGEntity.tenant_id == self.tenant_id,
|
||
or_(*conds),
|
||
)
|
||
)
|
||
.order_by(KGEntity.last_seen_at.desc(), KGEntity.id.desc())
|
||
.limit(15)
|
||
)
|
||
).scalars().all()
|
||
if not seed_entities and anchor_ids:
|
||
seed_entities = (
|
||
await self.db.execute(
|
||
select(KGEntity).where(
|
||
and_(
|
||
KGEntity.tenant_id == self.tenant_id,
|
||
KGEntity.id.in_(anchor_ids[:10]),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
if not seed_entities:
|
||
return []
|
||
|
||
seed_ids = [int(x.id) for x in seed_entities]
|
||
relation_rows = (
|
||
await self.db.execute(
|
||
select(KGRelation)
|
||
.where(
|
||
and_(
|
||
KGRelation.tenant_id == self.tenant_id,
|
||
or_(
|
||
KGRelation.subject_entity_id.in_(seed_ids),
|
||
KGRelation.object_entity_id.in_(seed_ids),
|
||
),
|
||
)
|
||
)
|
||
.order_by(KGRelation.weight.desc(), KGRelation.id.desc())
|
||
.limit(120)
|
||
)
|
||
).scalars().all()
|
||
|
||
entity_ids: set[int] = set(seed_ids)
|
||
for rel in relation_rows:
|
||
entity_ids.add(int(rel.subject_entity_id))
|
||
entity_ids.add(int(rel.object_entity_id))
|
||
entities = (
|
||
await self.db.execute(
|
||
select(KGEntity).where(
|
||
and_(
|
||
KGEntity.tenant_id == self.tenant_id,
|
||
KGEntity.id.in_(list(entity_ids)),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
entity_map = {int(row.id): row for row in entities}
|
||
|
||
relation_ids = [int(row.id) for row in relation_rows]
|
||
evidence_rows: list[KGEvidence] = []
|
||
if relation_ids or entity_ids:
|
||
evidence_rows = (
|
||
await self.db.execute(
|
||
select(KGEvidence)
|
||
.where(
|
||
and_(
|
||
KGEvidence.tenant_id == self.tenant_id,
|
||
or_(
|
||
KGEvidence.relation_id.in_(relation_ids or [-1]),
|
||
KGEvidence.entity_id.in_(list(entity_ids) or [-1]),
|
||
),
|
||
)
|
||
)
|
||
.order_by(KGEvidence.occurred_at.desc(), KGEvidence.id.desc())
|
||
.limit(220)
|
||
)
|
||
).scalars().all()
|
||
evidence_by_relation: dict[int, str] = {}
|
||
evidence_by_entity: dict[int, str] = {}
|
||
for ev in evidence_rows:
|
||
excerpt = str(ev.content_excerpt or "").strip()
|
||
if not excerpt:
|
||
continue
|
||
if ev.relation_id is not None and int(ev.relation_id) not in evidence_by_relation:
|
||
evidence_by_relation[int(ev.relation_id)] = excerpt[:320]
|
||
if ev.entity_id is not None and int(ev.entity_id) not in evidence_by_entity:
|
||
evidence_by_entity[int(ev.entity_id)] = excerpt[:320]
|
||
|
||
out: list[RetrievedItem] = []
|
||
for rel in relation_rows:
|
||
subject = entity_map.get(int(rel.subject_entity_id))
|
||
target = entity_map.get(int(rel.object_entity_id))
|
||
if subject is None or target is None:
|
||
continue
|
||
subject_payload = self._safe_dict(subject.payload)
|
||
target_payload = self._safe_dict(target.payload)
|
||
if project_id is not None:
|
||
subject_pid = subject_payload.get("project_id")
|
||
target_pid = target_payload.get("project_id")
|
||
if subject_pid is not None and int(subject_pid) != int(project_id):
|
||
if target_pid is not None and int(target_pid) != int(project_id):
|
||
continue
|
||
evidence_text = evidence_by_relation.get(int(rel.id))
|
||
if not evidence_text:
|
||
evidence_text = evidence_by_entity.get(int(subject.id)) or evidence_by_entity.get(int(target.id)) or ""
|
||
content = (
|
||
f"图路径: {str(subject.display_name or subject.entity_key)} --[{str(rel.relation_type)}]--> "
|
||
f"{str(target.display_name or target.entity_key)}\n"
|
||
f"证据: {evidence_text or '无显式证据摘录'}"
|
||
)
|
||
weight = self._to_float(rel.weight, 1.0)
|
||
score = min(0.99, max(0.45, 0.62 + min(weight, 1.5) * 0.18))
|
||
out.append(
|
||
RetrievedItem(
|
||
source="graph",
|
||
content=content[:1200],
|
||
score=score,
|
||
source_label="Knowledge Graph",
|
||
metadata={
|
||
"relation_id": int(rel.id),
|
||
"relation_type": str(rel.relation_type or ""),
|
||
"subject_entity_id": int(subject.id),
|
||
"object_entity_id": int(target.id),
|
||
"subject_name": str(subject.display_name or subject.entity_key),
|
||
"object_name": str(target.display_name or target.entity_key),
|
||
"path": f"{subject.id}->{str(rel.relation_type)}->{target.id}",
|
||
},
|
||
)
|
||
)
|
||
|
||
if not out:
|
||
for seed in seed_entities[:DEFAULT_TOP_K]:
|
||
payload = self._safe_dict(seed.payload)
|
||
if project_id is not None and payload.get("project_id") is not None:
|
||
if int(payload.get("project_id")) != int(project_id):
|
||
continue
|
||
excerpt = evidence_by_entity.get(int(seed.id)) or ""
|
||
out.append(
|
||
RetrievedItem(
|
||
source="graph",
|
||
content=f"实体: {str(seed.display_name or seed.entity_key)}\n证据: {excerpt or '无显式证据摘录'}",
|
||
score=0.58,
|
||
source_label="Knowledge Graph",
|
||
metadata={
|
||
"entity_id": int(seed.id),
|
||
"entity_type": str(seed.entity_type or ""),
|
||
"entity_key": str(seed.entity_key or ""),
|
||
},
|
||
)
|
||
)
|
||
out.sort(key=lambda x: x.score, reverse=True)
|
||
return out[: max(DEFAULT_TOP_K, 6)]
|
||
except Exception:
|
||
return []
|
||
|
||
async def _retrieve_vector_context(
|
||
self,
|
||
question: str,
|
||
*,
|
||
domains: list[str],
|
||
project_id: Optional[int],
|
||
source_chat_id: Optional[str],
|
||
top_k: Optional[int] = None,
|
||
) -> list[RetrievedItem]:
|
||
question_embedding = await self.llm.get_embedding_async(
|
||
question,
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
)
|
||
if not question_embedding:
|
||
return []
|
||
|
||
source_types = self._map_domains_to_source_types(domains)
|
||
if not source_types:
|
||
return []
|
||
limit = max(1, min(int(top_k or DEFAULT_TOP_K), 40))
|
||
if self._is_postgresql_session():
|
||
pg_rows = await self._retrieve_vector_context_pg(
|
||
question_embedding=question_embedding,
|
||
source_types=source_types,
|
||
project_id=project_id,
|
||
source_chat_id=source_chat_id,
|
||
limit=limit,
|
||
)
|
||
if pg_rows is not None:
|
||
return pg_rows
|
||
|
||
return await self._retrieve_vector_context_legacy(
|
||
question_embedding=question_embedding,
|
||
source_types=source_types,
|
||
project_id=project_id,
|
||
source_chat_id=source_chat_id,
|
||
limit=limit,
|
||
)
|
||
|
||
def _is_postgresql_session(self) -> bool:
|
||
try:
|
||
bind = self.db.get_bind()
|
||
except Exception:
|
||
return False
|
||
dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "")).lower()
|
||
return dialect_name.startswith("postgres")
|
||
|
||
@staticmethod
|
||
def _vector_literal(values: list[float]) -> str:
|
||
normalized: list[str] = []
|
||
for raw in values:
|
||
try:
|
||
normalized.append(f"{float(raw):.8f}")
|
||
except Exception:
|
||
normalized.append("0.0")
|
||
return "[" + ",".join(normalized) + "]"
|
||
|
||
async def _retrieve_vector_context_pg(
|
||
self,
|
||
*,
|
||
question_embedding: list[float],
|
||
source_types: list[str],
|
||
project_id: Optional[int],
|
||
source_chat_id: Optional[str],
|
||
limit: int,
|
||
) -> Optional[list[RetrievedItem]]:
|
||
query_vec = self._vector_literal(question_embedding)
|
||
scoped_project_id = int(project_id) if project_id is not None else 0
|
||
scoped_source_chat_id = str(source_chat_id or "").strip()
|
||
stmt = text(
|
||
"""
|
||
SELECT
|
||
id,
|
||
source_type,
|
||
content,
|
||
project_id,
|
||
"metadata" AS chunk_metadata,
|
||
1 - (embedding <=> CAST(:query_vec AS vector)) AS similarity
|
||
FROM knowledge_base_chunks
|
||
WHERE tenant_id = :tenant_id
|
||
AND source_type IN :source_types
|
||
AND embedding IS NOT NULL
|
||
AND (
|
||
:project_scope = FALSE
|
||
OR (source_type IN ('feishu', 'pmflow') AND project_id = :project_id)
|
||
OR (source_type NOT IN ('feishu', 'pmflow') AND (project_id = :project_id OR project_id IS NULL))
|
||
)
|
||
AND (
|
||
:source_chat_id = ''
|
||
OR source_type <> 'feishu'
|
||
OR COALESCE("metadata"->>'chat_id', '') = ''
|
||
OR "metadata"->>'chat_id' = :source_chat_id
|
||
)
|
||
ORDER BY embedding <=> CAST(:query_vec AS vector)
|
||
LIMIT :limit
|
||
"""
|
||
).bindparams(bindparam("source_types", expanding=True))
|
||
try:
|
||
rows = (
|
||
await self.db.execute(
|
||
stmt,
|
||
{
|
||
"tenant_id": self.tenant_id,
|
||
"source_types": list(source_types),
|
||
"project_scope": project_id is not None,
|
||
"project_id": scoped_project_id,
|
||
"source_chat_id": scoped_source_chat_id,
|
||
"query_vec": query_vec,
|
||
"limit": int(limit),
|
||
},
|
||
)
|
||
).mappings().all()
|
||
except Exception:
|
||
return None
|
||
|
||
scored: list[RetrievedItem] = []
|
||
for row in rows:
|
||
source_type = str(row.get("source_type") or "doc")
|
||
meta = self._safe_dict(row.get("chunk_metadata")).copy()
|
||
chunk_project_id_raw = row.get("project_id")
|
||
chunk_project_id = int(chunk_project_id_raw) if chunk_project_id_raw is not None else None
|
||
if chunk_project_id is not None:
|
||
meta.setdefault("project_id", chunk_project_id)
|
||
|
||
if source_type == "pmflow":
|
||
target_url = str(meta.get("target_url") or "").strip()
|
||
if not target_url:
|
||
target_url = self._build_pmflow_target_url(
|
||
record_type=str(meta.get("record_type") or ""),
|
||
record_id=meta.get("record_id"),
|
||
project_id=meta.get("project_id"),
|
||
)
|
||
if target_url:
|
||
meta["target_url"] = target_url
|
||
|
||
score = self._to_float(row.get("similarity"), 0.0)
|
||
if score <= 0:
|
||
continue
|
||
label = self._source_label(source_type, meta)
|
||
scored.append(
|
||
RetrievedItem(
|
||
source=source_type,
|
||
content=str(row.get("content") or "")[:1200],
|
||
score=score,
|
||
source_label=label,
|
||
metadata=meta,
|
||
)
|
||
)
|
||
scored.sort(key=lambda x: x.score, reverse=True)
|
||
return scored[:limit]
|
||
|
||
async def _retrieve_vector_context_legacy(
|
||
self,
|
||
*,
|
||
question_embedding: list[float],
|
||
source_types: list[str],
|
||
project_id: Optional[int],
|
||
source_chat_id: Optional[str],
|
||
limit: int,
|
||
) -> list[RetrievedItem]:
|
||
stmt = select(KnowledgeBaseChunk).where(
|
||
and_(
|
||
KnowledgeBaseChunk.tenant_id == self.tenant_id,
|
||
KnowledgeBaseChunk.source_type.in_(source_types),
|
||
)
|
||
)
|
||
|
||
scan_limit = max(120, min(1200, limit * 20))
|
||
chunks = (await self.db.execute(stmt.limit(scan_limit))).scalars().all()
|
||
scored: list[RetrievedItem] = []
|
||
for chunk in chunks:
|
||
meta = self._safe_dict(chunk.chunk_metadata).copy()
|
||
chunk_project_id = int(chunk.project_id) if chunk.project_id is not None else None
|
||
if chunk_project_id is not None:
|
||
meta.setdefault("project_id", chunk_project_id)
|
||
|
||
if project_id is not None:
|
||
if chunk.source_type in {"feishu", "pmflow"}:
|
||
if chunk_project_id != int(project_id):
|
||
continue
|
||
elif chunk_project_id is not None and chunk_project_id != int(project_id):
|
||
continue
|
||
|
||
if chunk.source_type == "feishu" and source_chat_id:
|
||
chat_id = str(meta.get("chat_id") or "").strip()
|
||
if chat_id and chat_id != str(source_chat_id):
|
||
continue
|
||
if chunk.source_type == "pmflow":
|
||
target_url = str(meta.get("target_url") or "").strip()
|
||
if not target_url:
|
||
target_url = self._build_pmflow_target_url(
|
||
record_type=str(meta.get("record_type") or ""),
|
||
record_id=meta.get("record_id"),
|
||
project_id=meta.get("project_id"),
|
||
)
|
||
if target_url:
|
||
meta["target_url"] = target_url
|
||
score = self._cosine_similarity(question_embedding, self._embedding_to_list(chunk.embedding))
|
||
if score <= 0:
|
||
continue
|
||
label = self._source_label(chunk.source_type, meta)
|
||
scored.append(
|
||
RetrievedItem(
|
||
source=str(chunk.source_type or "doc"),
|
||
content=str(chunk.content or "")[:1200],
|
||
score=score,
|
||
source_label=label,
|
||
metadata=meta,
|
||
)
|
||
)
|
||
scored.sort(key=lambda x: x.score, reverse=True)
|
||
return scored[:limit]
|
||
|
||
# ------------------------------------------------------------------
|
||
# Knowledge indexing
|
||
# ------------------------------------------------------------------
|
||
async def create_knowledge_doc(
|
||
self,
|
||
*,
|
||
title: str,
|
||
file_type: str,
|
||
raw_content: str,
|
||
uploaded_by: Optional[int],
|
||
source_type: str = "file",
|
||
file_url: Optional[str] = None,
|
||
project_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
doc = await self.create_knowledge_doc_placeholder(
|
||
title=title,
|
||
file_type=file_type,
|
||
uploaded_by=uploaded_by,
|
||
source_type=source_type,
|
||
file_url=file_url,
|
||
)
|
||
await self._index_doc_content(
|
||
doc=doc,
|
||
raw_content=raw_content,
|
||
project_id=project_id,
|
||
source_type=source_type,
|
||
)
|
||
return self._serialize_doc(doc)
|
||
|
||
async def create_knowledge_doc_placeholder(
|
||
self,
|
||
*,
|
||
title: str,
|
||
file_type: str,
|
||
uploaded_by: Optional[int],
|
||
source_type: str = "file",
|
||
file_url: Optional[str] = None,
|
||
) -> KnowledgeBaseDoc:
|
||
doc = KnowledgeBaseDoc(
|
||
tenant_id=self.tenant_id,
|
||
title=(title or "未命名文档")[:255],
|
||
file_type=(file_type or "txt")[:32],
|
||
source_type=(source_type or "file")[:32],
|
||
file_url=(file_url or "")[:1024] or None,
|
||
uploaded_by=uploaded_by,
|
||
status="processing",
|
||
last_fetched_at=datetime.now(timezone.utc) if source_type == "url" else None,
|
||
)
|
||
self.db.add(doc)
|
||
await self.db.commit()
|
||
await self.db.refresh(doc)
|
||
return doc
|
||
|
||
async def process_knowledge_doc_content(
|
||
self,
|
||
*,
|
||
doc_id: int,
|
||
raw_content: str,
|
||
project_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
doc = (
|
||
await self.db.execute(
|
||
select(KnowledgeBaseDoc).where(
|
||
and_(
|
||
KnowledgeBaseDoc.id == int(doc_id),
|
||
KnowledgeBaseDoc.tenant_id == self.tenant_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not doc:
|
||
raise ValueError("doc_not_found")
|
||
await self._index_doc_content(
|
||
doc=doc,
|
||
raw_content=raw_content,
|
||
project_id=project_id,
|
||
source_type=str(doc.source_type or "file"),
|
||
)
|
||
return self._serialize_doc(doc)
|
||
|
||
async def _index_doc_content(
|
||
self,
|
||
*,
|
||
doc: KnowledgeBaseDoc,
|
||
raw_content: str,
|
||
project_id: Optional[int],
|
||
source_type: str,
|
||
) -> None:
|
||
clean_content = str(raw_content or "").strip()
|
||
if not clean_content:
|
||
doc.status = "failed"
|
||
doc.error_message = "文档内容为空,无法向量化"
|
||
await self.db.commit()
|
||
return
|
||
|
||
try:
|
||
await self.db.execute(
|
||
delete(KnowledgeBaseChunk).where(
|
||
and_(
|
||
KnowledgeBaseChunk.tenant_id == self.tenant_id,
|
||
KnowledgeBaseChunk.doc_id == int(doc.id),
|
||
)
|
||
)
|
||
)
|
||
|
||
chunk_items = self._chunk_text_with_metadata(clean_content, chunk_size=500, overlap=50)
|
||
for idx, (part, extra_meta) in enumerate(chunk_items):
|
||
emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id)
|
||
chunk_metadata = {
|
||
"title": doc.title,
|
||
"file_type": doc.file_type,
|
||
"source_type": source_type,
|
||
"file_url": doc.file_url,
|
||
}
|
||
chunk_metadata.update(extra_meta)
|
||
self.db.add(
|
||
KnowledgeBaseChunk(
|
||
tenant_id=self.tenant_id,
|
||
doc_id=int(doc.id),
|
||
source_type="url" if source_type == "url" else "doc",
|
||
project_id=project_id,
|
||
content=part,
|
||
embedding=emb if emb else None,
|
||
chunk_index=idx,
|
||
chunk_metadata=chunk_metadata,
|
||
)
|
||
)
|
||
doc.status = "complete"
|
||
doc.error_message = None
|
||
if source_type == "url":
|
||
doc.last_fetched_at = datetime.now(timezone.utc)
|
||
await self.db.commit()
|
||
except Exception as exc:
|
||
doc.status = "failed"
|
||
doc.error_message = str(exc)[:500]
|
||
await self.db.commit()
|
||
raise
|
||
|
||
async def list_knowledge_docs(self) -> list[dict[str, Any]]:
|
||
docs = (
|
||
await self.db.execute(
|
||
select(KnowledgeBaseDoc)
|
||
.where(KnowledgeBaseDoc.tenant_id == self.tenant_id)
|
||
.order_by(KnowledgeBaseDoc.created_at.desc())
|
||
.limit(200)
|
||
)
|
||
).scalars().all()
|
||
return [
|
||
self._serialize_doc(d)
|
||
for d in docs
|
||
]
|
||
|
||
async def get_knowledge_doc(self, *, doc_id: int) -> dict[str, Any]:
|
||
doc = (
|
||
await self.db.execute(
|
||
select(KnowledgeBaseDoc).where(
|
||
and_(
|
||
KnowledgeBaseDoc.id == int(doc_id),
|
||
KnowledgeBaseDoc.tenant_id == self.tenant_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not doc:
|
||
raise ValueError("doc_not_found")
|
||
return self._serialize_doc(doc)
|
||
|
||
async def delete_knowledge_doc(self, *, doc_id: int) -> bool:
|
||
doc = (
|
||
await self.db.execute(
|
||
select(KnowledgeBaseDoc).where(
|
||
and_(
|
||
KnowledgeBaseDoc.id == int(doc_id),
|
||
KnowledgeBaseDoc.tenant_id == self.tenant_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not doc:
|
||
return False
|
||
|
||
await self.db.execute(
|
||
delete(KnowledgeBaseChunk).where(
|
||
and_(
|
||
KnowledgeBaseChunk.doc_id == int(doc_id),
|
||
KnowledgeBaseChunk.tenant_id == self.tenant_id,
|
||
)
|
||
)
|
||
)
|
||
await self.db.delete(doc)
|
||
await self.db.commit()
|
||
return True
|
||
|
||
async def ingest_feishu_message(
|
||
self,
|
||
*,
|
||
content: str,
|
||
sender: str,
|
||
chat_id: str,
|
||
message_id: Optional[str],
|
||
captured_at: Optional[datetime] = None,
|
||
project_id: Optional[int] = None,
|
||
) -> None:
|
||
text = str(content or "").strip()
|
||
if not text:
|
||
return
|
||
chunks = self._chunk_text(text, chunk_size=320, overlap=40)
|
||
for idx, part in enumerate(chunks):
|
||
emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id)
|
||
self.db.add(
|
||
KnowledgeBaseChunk(
|
||
tenant_id=self.tenant_id,
|
||
doc_id=None,
|
||
source_type="feishu",
|
||
project_id=project_id,
|
||
content=part,
|
||
embedding=emb if emb else None,
|
||
chunk_index=idx,
|
||
chunk_metadata={
|
||
"sender": sender,
|
||
"chat_id": chat_id,
|
||
"message_id": message_id,
|
||
"captured_at": (captured_at or datetime.now(timezone.utc)).isoformat(),
|
||
},
|
||
)
|
||
)
|
||
await self.db.commit()
|
||
|
||
async def mark_doc_processing(self, *, doc_id: int) -> dict[str, Any]:
|
||
doc = (
|
||
await self.db.execute(
|
||
select(KnowledgeBaseDoc).where(
|
||
and_(
|
||
KnowledgeBaseDoc.id == int(doc_id),
|
||
KnowledgeBaseDoc.tenant_id == self.tenant_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not doc:
|
||
raise ValueError("doc_not_found")
|
||
doc.status = "processing"
|
||
doc.error_message = None
|
||
await self.db.commit()
|
||
await self.db.refresh(doc)
|
||
return self._serialize_doc(doc)
|
||
|
||
@staticmethod
|
||
async def ingest_feishu_message_async(
|
||
*,
|
||
tenant_id: int,
|
||
content: str,
|
||
sender: str,
|
||
chat_id: str,
|
||
message_id: Optional[str],
|
||
captured_at: Optional[datetime] = None,
|
||
project_id: Optional[int] = None,
|
||
) -> None:
|
||
async with AsyncSessionLocal() as session:
|
||
service = AdaptiveRAGService(session, tenant_id=tenant_id)
|
||
try:
|
||
await service.ingest_feishu_message(
|
||
content=content,
|
||
sender=sender,
|
||
chat_id=chat_id,
|
||
message_id=message_id,
|
||
captured_at=captured_at,
|
||
project_id=project_id,
|
||
)
|
||
except Exception:
|
||
await session.rollback()
|
||
|
||
@staticmethod
|
||
async def process_knowledge_doc_content_async(
|
||
*,
|
||
tenant_id: int,
|
||
doc_id: int,
|
||
raw_content: str,
|
||
project_id: Optional[int] = None,
|
||
) -> None:
|
||
async with AsyncSessionLocal() as session:
|
||
service = AdaptiveRAGService(session, tenant_id=tenant_id)
|
||
try:
|
||
await service.process_knowledge_doc_content(
|
||
doc_id=doc_id,
|
||
raw_content=raw_content,
|
||
project_id=project_id,
|
||
)
|
||
except Exception:
|
||
await session.rollback()
|
||
|
||
async def refresh_url_doc(self, *, doc_id: int, project_id: Optional[int] = None) -> dict[str, Any]:
|
||
doc = (
|
||
await self.db.execute(
|
||
select(KnowledgeBaseDoc).where(
|
||
and_(
|
||
KnowledgeBaseDoc.id == int(doc_id),
|
||
KnowledgeBaseDoc.tenant_id == self.tenant_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not doc:
|
||
raise ValueError("doc_not_found")
|
||
if str(doc.source_type or "").lower() != "url":
|
||
raise ValueError("doc_is_not_url_source")
|
||
if not str(doc.file_url or "").strip():
|
||
raise ValueError("doc_url_missing")
|
||
text = await self._fetch_url_text(str(doc.file_url))
|
||
await self._index_doc_content(
|
||
doc=doc,
|
||
raw_content=text,
|
||
project_id=project_id,
|
||
source_type="url",
|
||
)
|
||
await self.db.refresh(doc)
|
||
return self._serialize_doc(doc)
|
||
|
||
@staticmethod
|
||
async def refresh_url_doc_async(
|
||
*,
|
||
tenant_id: int,
|
||
doc_id: int,
|
||
project_id: Optional[int] = None,
|
||
) -> None:
|
||
async with AsyncSessionLocal() as session:
|
||
service = AdaptiveRAGService(session, tenant_id=tenant_id)
|
||
try:
|
||
await service.refresh_url_doc(doc_id=doc_id, project_id=project_id)
|
||
except Exception:
|
||
await session.rollback()
|
||
|
||
async def upsert_pmflow_record(
|
||
self,
|
||
*,
|
||
record_type: str,
|
||
record_id: int,
|
||
project_id: Optional[int],
|
||
title: str,
|
||
content: str,
|
||
) -> None:
|
||
safe_type = str(record_type or "").strip().lower()[:32]
|
||
safe_id = int(record_id)
|
||
if not safe_type or safe_id <= 0:
|
||
return
|
||
|
||
stmt = select(KnowledgeBaseChunk).where(
|
||
and_(
|
||
KnowledgeBaseChunk.tenant_id == self.tenant_id,
|
||
KnowledgeBaseChunk.source_type == "pmflow",
|
||
)
|
||
)
|
||
if project_id is not None:
|
||
stmt = stmt.where(KnowledgeBaseChunk.project_id == int(project_id))
|
||
existing = (await self.db.execute(stmt.limit(2000))).scalars().all()
|
||
for row in existing:
|
||
meta = self._safe_dict(row.chunk_metadata)
|
||
if str(meta.get("record_type") or "").strip().lower() != safe_type:
|
||
continue
|
||
if str(meta.get("record_id") or "").strip() != str(safe_id):
|
||
continue
|
||
await self.db.delete(row)
|
||
|
||
text = f"{title}\n\n{content}".strip()
|
||
chunks = self._chunk_text(text, chunk_size=500, overlap=50)
|
||
for idx, part in enumerate(chunks):
|
||
emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id)
|
||
target_url = self._build_pmflow_target_url(
|
||
record_type=safe_type,
|
||
record_id=safe_id,
|
||
project_id=project_id,
|
||
)
|
||
self.db.add(
|
||
KnowledgeBaseChunk(
|
||
tenant_id=self.tenant_id,
|
||
doc_id=None,
|
||
source_type="pmflow",
|
||
project_id=project_id,
|
||
content=part,
|
||
embedding=emb if emb else None,
|
||
chunk_index=idx,
|
||
chunk_metadata={
|
||
"record_type": safe_type,
|
||
"record_id": safe_id,
|
||
"project_id": int(project_id) if project_id is not None else None,
|
||
"target_url": target_url,
|
||
"title": title[:255],
|
||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||
},
|
||
)
|
||
)
|
||
await self.db.commit()
|
||
|
||
@staticmethod
|
||
async def upsert_pmflow_record_async(
|
||
*,
|
||
tenant_id: int,
|
||
record_type: str,
|
||
record_id: int,
|
||
project_id: Optional[int],
|
||
title: str,
|
||
content: str,
|
||
) -> None:
|
||
async with AsyncSessionLocal() as session:
|
||
service = AdaptiveRAGService(session, tenant_id=tenant_id)
|
||
try:
|
||
await service.upsert_pmflow_record(
|
||
record_type=record_type,
|
||
record_id=record_id,
|
||
project_id=project_id,
|
||
title=title,
|
||
content=content,
|
||
)
|
||
except Exception:
|
||
await session.rollback()
|
||
|
||
@staticmethod
|
||
def _build_intent_correction_record(
|
||
*,
|
||
user_utterance: str,
|
||
wrong_interpretation: str,
|
||
correct_intent: str,
|
||
canonical_action: str,
|
||
clarification_template: str,
|
||
risk_level: str,
|
||
project_id: Optional[int],
|
||
) -> tuple[int, str, str, dict[str, Any]]:
|
||
utterance = " ".join(str(user_utterance or "").split()).strip()[:500]
|
||
wrong = " ".join(str(wrong_interpretation or "").split()).strip()[:300]
|
||
correct = " ".join(str(correct_intent or "").split()).strip().lower()[:60]
|
||
action = " ".join(str(canonical_action or "").split()).strip().lower()[:80]
|
||
clarification = " ".join(str(clarification_template or "").split()).strip()[:500]
|
||
risk = " ".join(str(risk_level or "medium").split()).strip().lower()[:20] or "medium"
|
||
|
||
fingerprint = hashlib.sha1(
|
||
"||".join([utterance, wrong, correct, action, clarification, risk]).encode("utf-8")
|
||
).hexdigest()
|
||
record_id = int(fingerprint[:12], 16)
|
||
title = f"意图纠错/{correct or 'unknown'}"
|
||
content = (
|
||
f"用户原话: {utterance}\n"
|
||
f"错误理解: {wrong}\n"
|
||
f"正确意图: {correct}\n"
|
||
f"标准动作: {action}\n"
|
||
f"澄清模板: {clarification}\n"
|
||
f"风险等级: {risk}"
|
||
)[:4000]
|
||
metadata = {
|
||
"record_type": "intent_correction",
|
||
"record_id": str(record_id),
|
||
"project_id": int(project_id) if project_id is not None else None,
|
||
"user_utterance": utterance,
|
||
"wrong_interpretation": wrong,
|
||
"correct_intent": correct,
|
||
"canonical_action": action,
|
||
"clarification_template": clarification,
|
||
"risk_level": risk,
|
||
"target_url": None,
|
||
"title": title,
|
||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
return record_id, title, content, metadata
|
||
|
||
async def upsert_intent_correction_sample(
|
||
self,
|
||
*,
|
||
user_utterance: str,
|
||
wrong_interpretation: str,
|
||
correct_intent: str,
|
||
canonical_action: str,
|
||
clarification_template: str,
|
||
risk_level: str,
|
||
project_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
record_id, title, content, metadata = self._build_intent_correction_record(
|
||
user_utterance=user_utterance,
|
||
wrong_interpretation=wrong_interpretation,
|
||
correct_intent=correct_intent,
|
||
canonical_action=canonical_action,
|
||
clarification_template=clarification_template,
|
||
risk_level=risk_level,
|
||
project_id=project_id,
|
||
)
|
||
if not metadata["user_utterance"] or not metadata["correct_intent"]:
|
||
raise ValueError("intent correction sample missing required fields")
|
||
|
||
stmt = select(KnowledgeBaseChunk).where(
|
||
and_(
|
||
KnowledgeBaseChunk.tenant_id == self.tenant_id,
|
||
KnowledgeBaseChunk.source_type == "pmflow",
|
||
)
|
||
)
|
||
existing = (await self.db.execute(stmt.limit(2000))).scalars().all()
|
||
for row in existing:
|
||
meta = self._safe_dict(row.chunk_metadata)
|
||
if str(meta.get("record_type") or "").strip().lower() != "intent_correction":
|
||
continue
|
||
if str(meta.get("record_id") or "").strip() != str(record_id):
|
||
continue
|
||
await self.db.delete(row)
|
||
|
||
embedding = await self.llm.get_embedding_async(content, db=self.db, tenant_id=self.tenant_id)
|
||
self.db.add(
|
||
KnowledgeBaseChunk(
|
||
tenant_id=self.tenant_id,
|
||
doc_id=None,
|
||
source_type="pmflow",
|
||
project_id=int(project_id) if project_id is not None else None,
|
||
content=content,
|
||
embedding=embedding if embedding else None,
|
||
chunk_index=0,
|
||
chunk_metadata=metadata,
|
||
)
|
||
)
|
||
await self.db.commit()
|
||
return metadata
|
||
|
||
async def retrieve_intent_correction_samples(
|
||
self,
|
||
*,
|
||
utterance: str,
|
||
top_k: int = 3,
|
||
project_id: Optional[int] = None,
|
||
) -> list[dict[str, Any]]:
|
||
text = " ".join(str(utterance or "").split()).strip()
|
||
if len(text) < 2:
|
||
return []
|
||
question_embedding = await self.llm.get_embedding_async(
|
||
text,
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
)
|
||
if not question_embedding:
|
||
return []
|
||
|
||
rows = (
|
||
await self.db.execute(
|
||
select(KnowledgeBaseChunk).where(
|
||
and_(
|
||
KnowledgeBaseChunk.tenant_id == self.tenant_id,
|
||
KnowledgeBaseChunk.source_type == "pmflow",
|
||
)
|
||
).limit(500)
|
||
)
|
||
).scalars().all()
|
||
scored: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
meta = self._safe_dict(row.chunk_metadata)
|
||
if str(meta.get("record_type") or "").strip().lower() != "intent_correction":
|
||
continue
|
||
try:
|
||
chunk_project_id = int(meta.get("project_id")) if meta.get("project_id") is not None else None
|
||
except Exception:
|
||
chunk_project_id = None
|
||
if project_id is not None and chunk_project_id is not None and chunk_project_id != int(project_id):
|
||
continue
|
||
score = self._cosine_similarity(question_embedding, self._embedding_to_list(row.embedding))
|
||
if score <= 0:
|
||
continue
|
||
scored.append(
|
||
{
|
||
"score": round(float(score), 4),
|
||
"record_id": str(meta.get("record_id") or ""),
|
||
"user_utterance": str(meta.get("user_utterance") or ""),
|
||
"wrong_interpretation": str(meta.get("wrong_interpretation") or ""),
|
||
"correct_intent": str(meta.get("correct_intent") or ""),
|
||
"canonical_action": str(meta.get("canonical_action") or ""),
|
||
"clarification_template": str(meta.get("clarification_template") or ""),
|
||
"risk_level": str(meta.get("risk_level") or "medium"),
|
||
"project_id": chunk_project_id,
|
||
}
|
||
)
|
||
scored.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True)
|
||
return scored[: max(1, min(int(top_k or 3), 10))]
|
||
|
||
# ------------------------------------------------------------------
|
||
# QA session helpers
|
||
# ------------------------------------------------------------------
|
||
@staticmethod
|
||
def _to_naive_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||
if value is None:
|
||
return None
|
||
if value.tzinfo is None:
|
||
return value
|
||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||
|
||
@staticmethod
|
||
def _normalize_session_title_raw(text: str, *, max_len: int = 30) -> str:
|
||
compact = " ".join(str(text or "").replace("\n", " ").split()).strip()
|
||
if not compact:
|
||
return ""
|
||
return compact[:max_len]
|
||
|
||
@classmethod
|
||
def _build_auto_session_title(cls, question: str) -> Optional[str]:
|
||
normalized = cls._normalize_session_title_raw(question, max_len=30)
|
||
if not normalized:
|
||
return None
|
||
compact = re.sub(r"\s+", "", normalized)
|
||
if len(compact) < SESSION_TITLE_MIN_LEN:
|
||
return None
|
||
return normalized
|
||
|
||
@staticmethod
|
||
def _is_default_session_title(title: Optional[str]) -> bool:
|
||
clean = str(title or "").strip()
|
||
return (not clean) or clean == SESSION_DEFAULT_TITLE
|
||
|
||
async def _touch_session_for_new_record(
|
||
self,
|
||
*,
|
||
asked_by: Optional[int],
|
||
asked_via: str,
|
||
project_id: Optional[int],
|
||
question: str,
|
||
preferred_session_id: Optional[int] = None,
|
||
force_new_session: bool = False,
|
||
) -> tuple[Optional[int], int]:
|
||
if asked_by is None:
|
||
return (None, 1)
|
||
|
||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||
chosen: Optional[QASession] = None
|
||
preferred_id = int(preferred_session_id) if preferred_session_id else 0
|
||
if preferred_id > 0:
|
||
chosen = (
|
||
await self.db.execute(
|
||
select(QASession).where(
|
||
and_(
|
||
QASession.tenant_id == self.tenant_id,
|
||
QASession.user_id == int(asked_by),
|
||
QASession.id == preferred_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
# 无显式 session_id 时,仅在非强制新会话模式下复用同渠道(asked_via)且时间窗口内的最近会话。
|
||
if chosen is None and not force_new_session:
|
||
latest_record = (
|
||
await self.db.execute(
|
||
select(QARecord.session_id, QARecord.created_at)
|
||
.where(
|
||
and_(
|
||
QARecord.tenant_id == self.tenant_id,
|
||
QARecord.asked_by == int(asked_by),
|
||
QARecord.asked_via == (asked_via or "pmflow")[:20],
|
||
QARecord.session_id.is_not(None),
|
||
)
|
||
)
|
||
.order_by(QARecord.created_at.desc(), QARecord.id.desc())
|
||
.limit(1)
|
||
)
|
||
).first()
|
||
if latest_record and latest_record.session_id is not None:
|
||
last_at = self._to_naive_utc(latest_record.created_at)
|
||
if last_at is not None:
|
||
gap_seconds = abs((now_utc - last_at).total_seconds())
|
||
is_same_day = now_utc.date() == last_at.date()
|
||
if is_same_day and gap_seconds <= SESSION_GAP_MINUTES * 60:
|
||
chosen = (
|
||
await self.db.execute(
|
||
select(QASession).where(
|
||
and_(
|
||
QASession.tenant_id == self.tenant_id,
|
||
QASession.user_id == int(asked_by),
|
||
QASession.id == int(latest_record.session_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
if chosen is None:
|
||
title = self._build_auto_session_title(question) or SESSION_DEFAULT_TITLE
|
||
chosen = QASession(
|
||
tenant_id=self.tenant_id,
|
||
user_id=int(asked_by),
|
||
title=title[:120],
|
||
project_id=project_id,
|
||
started_at=now_utc,
|
||
last_message_at=now_utc,
|
||
message_count=0,
|
||
)
|
||
self.db.add(chosen)
|
||
await self.db.flush()
|
||
else:
|
||
if chosen.project_id is None and project_id is not None:
|
||
chosen.project_id = int(project_id)
|
||
chosen.last_message_at = now_utc
|
||
|
||
chosen.message_count = int(chosen.message_count or 0) + 1
|
||
if self._is_default_session_title(chosen.title):
|
||
generated = self._build_auto_session_title(question)
|
||
if generated:
|
||
chosen.title = generated[:120]
|
||
await self.db.flush()
|
||
return (int(chosen.id), int(chosen.message_count))
|
||
|
||
# ------------------------------------------------------------------
|
||
# QA feedback/list/export
|
||
# ------------------------------------------------------------------
|
||
async def _latest_feedback_map(self, record_ids: list[int]) -> dict[int, QAFeedback]:
|
||
ids = [int(x) for x in record_ids if x is not None]
|
||
if not ids:
|
||
return {}
|
||
feedback_rows = (
|
||
await self.db.execute(
|
||
select(QAFeedback).where(
|
||
and_(
|
||
QAFeedback.tenant_id == self.tenant_id,
|
||
QAFeedback.qa_id.in_(ids),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
|
||
latest_feedback: dict[int, QAFeedback] = {}
|
||
for fb in feedback_rows:
|
||
key = int(fb.qa_id)
|
||
curr = latest_feedback.get(key)
|
||
fb_at = self._to_naive_utc(fb.created_at) or datetime.min
|
||
curr_at = self._to_naive_utc(curr.created_at) if curr else None
|
||
if curr is None or curr_at is None or fb_at >= curr_at:
|
||
latest_feedback[key] = fb
|
||
return latest_feedback
|
||
|
||
async def list_qa_sessions(
|
||
self,
|
||
*,
|
||
user_id: int,
|
||
date_from: Optional[datetime],
|
||
date_to: Optional[datetime],
|
||
page: int,
|
||
page_size: int,
|
||
) -> dict[str, Any]:
|
||
page = max(1, int(page))
|
||
page_size = max(1, min(100, int(page_size)))
|
||
date_from_naive = self._to_naive_utc(date_from)
|
||
date_to_naive = self._to_naive_utc(date_to)
|
||
|
||
base_stmt = select(QASession).where(
|
||
and_(
|
||
QASession.tenant_id == self.tenant_id,
|
||
QASession.user_id == int(user_id),
|
||
)
|
||
)
|
||
if date_from_naive is not None:
|
||
base_stmt = base_stmt.where(QASession.last_message_at >= date_from_naive)
|
||
if date_to_naive is not None:
|
||
base_stmt = base_stmt.where(QASession.last_message_at <= date_to_naive)
|
||
|
||
total = int((await self.db.execute(select(func.count()).select_from(base_stmt.subquery()))).scalar() or 0)
|
||
rows = (
|
||
await self.db.execute(
|
||
base_stmt.order_by(QASession.last_message_at.desc(), QASession.id.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
if not rows:
|
||
return {
|
||
"items": [],
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"total": total,
|
||
"total_pages": 0,
|
||
"count": 0,
|
||
}
|
||
|
||
session_ids = [int(x.id) for x in rows]
|
||
record_rows = (
|
||
await self.db.execute(
|
||
select(QARecord).where(
|
||
and_(
|
||
QARecord.tenant_id == self.tenant_id,
|
||
QARecord.session_id.in_(session_ids),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
|
||
records_by_session: dict[int, list[QARecord]] = {}
|
||
for row in record_rows:
|
||
sid = int(row.session_id or 0)
|
||
if sid <= 0:
|
||
continue
|
||
records_by_session.setdefault(sid, []).append(row)
|
||
|
||
feedback_map = await self._latest_feedback_map([int(x.id) for x in record_rows])
|
||
items: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
sid = int(row.id)
|
||
session_records = sorted(
|
||
records_by_session.get(sid, []),
|
||
key=lambda r: (int(r.turn_index or 0), self._to_naive_utc(r.created_at) or datetime.min),
|
||
)
|
||
positive = 0
|
||
negative = 0
|
||
corrected = 0
|
||
last_asked_via = "pmflow"
|
||
for rec in session_records:
|
||
last_asked_via = str(rec.asked_via or last_asked_via)
|
||
fb = feedback_map.get(int(rec.id))
|
||
if not fb:
|
||
continue
|
||
rating = str(fb.rating or "").strip().lower()
|
||
if rating == "positive":
|
||
positive += 1
|
||
elif rating == "negative":
|
||
negative += 1
|
||
if str(fb.correct_answer or "").strip():
|
||
corrected += 1
|
||
|
||
message_count = int(row.message_count or len(session_records))
|
||
unrated = max(message_count - positive - negative, 0)
|
||
first_question = session_records[0].question if session_records else ""
|
||
items.append(
|
||
{
|
||
"id": sid,
|
||
"title": str(row.title or SESSION_DEFAULT_TITLE),
|
||
"project_id": row.project_id,
|
||
"started_at": row.started_at.isoformat() if row.started_at else None,
|
||
"last_message_at": row.last_message_at.isoformat() if row.last_message_at else None,
|
||
"message_count": message_count,
|
||
"turn_count": message_count,
|
||
"rating_summary": {
|
||
"positive": positive,
|
||
"negative": negative,
|
||
"unrated": unrated,
|
||
"corrected": corrected,
|
||
},
|
||
"last_asked_via": last_asked_via,
|
||
"preview_question": str(first_question or "")[:120],
|
||
}
|
||
)
|
||
|
||
total_pages = math.ceil(total / page_size) if total > 0 else 0
|
||
return {
|
||
"items": items,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"total": total,
|
||
"total_pages": total_pages,
|
||
"count": len(items),
|
||
}
|
||
|
||
async def get_qa_session_detail(self, *, session_id: int, user_id: int) -> dict[str, Any]:
|
||
session_row = (
|
||
await self.db.execute(
|
||
select(QASession).where(
|
||
and_(
|
||
QASession.id == int(session_id),
|
||
QASession.tenant_id == self.tenant_id,
|
||
QASession.user_id == int(user_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not session_row:
|
||
raise ValueError("qa session 不存在")
|
||
|
||
records = (
|
||
await self.db.execute(
|
||
select(QARecord).where(
|
||
and_(
|
||
QARecord.tenant_id == self.tenant_id,
|
||
QARecord.session_id == int(session_id),
|
||
)
|
||
).order_by(QARecord.turn_index.asc(), QARecord.created_at.asc())
|
||
)
|
||
).scalars().all()
|
||
feedback_map = await self._latest_feedback_map([int(x.id) for x in records])
|
||
|
||
messages: list[dict[str, Any]] = []
|
||
positive = 0
|
||
negative = 0
|
||
corrected = 0
|
||
for row in records:
|
||
fb = feedback_map.get(int(row.id))
|
||
ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer)
|
||
if fb:
|
||
rating = str(fb.rating or "").strip().lower()
|
||
if rating == "positive":
|
||
positive += 1
|
||
elif rating == "negative":
|
||
negative += 1
|
||
if str(fb.correct_answer or "").strip():
|
||
corrected += 1
|
||
messages.append(
|
||
{
|
||
"id": int(row.id),
|
||
"question": row.question,
|
||
"answer": row.answer,
|
||
"sources": row.sources or [],
|
||
"intent": row.intent,
|
||
"domains_used": row.domains_used or [],
|
||
"response_time_ms": row.response_time_ms,
|
||
"asked_by": row.asked_by,
|
||
"asked_via": row.asked_via,
|
||
"project_id": row.project_id,
|
||
"session_id": row.session_id,
|
||
"turn_index": int(row.turn_index or 1),
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
"feedback": {
|
||
"id": int(fb.id),
|
||
"rating": fb.rating,
|
||
"helpfulness": fb.helpfulness,
|
||
"error_types": fb.error_types or [],
|
||
"correct_answer": fb.correct_answer,
|
||
}
|
||
if fb
|
||
else None,
|
||
**ack_meta,
|
||
}
|
||
)
|
||
|
||
message_count = int(session_row.message_count or len(records))
|
||
unrated = max(message_count - positive - negative, 0)
|
||
return {
|
||
"session": {
|
||
"id": int(session_row.id),
|
||
"title": str(session_row.title or SESSION_DEFAULT_TITLE),
|
||
"project_id": session_row.project_id,
|
||
"started_at": session_row.started_at.isoformat() if session_row.started_at else None,
|
||
"last_message_at": session_row.last_message_at.isoformat() if session_row.last_message_at else None,
|
||
"message_count": message_count,
|
||
"turn_count": message_count,
|
||
"rating_summary": {
|
||
"positive": positive,
|
||
"negative": negative,
|
||
"unrated": unrated,
|
||
"corrected": corrected,
|
||
},
|
||
},
|
||
"messages": messages,
|
||
}
|
||
|
||
async def rename_qa_session(self, *, session_id: int, user_id: int, title: str) -> dict[str, Any]:
|
||
session_row = (
|
||
await self.db.execute(
|
||
select(QASession).where(
|
||
and_(
|
||
QASession.id == int(session_id),
|
||
QASession.tenant_id == self.tenant_id,
|
||
QASession.user_id == int(user_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not session_row:
|
||
raise ValueError("qa session 不存在")
|
||
|
||
cleaned = self._normalize_session_title_raw(title, max_len=120)
|
||
if not cleaned:
|
||
raise ValueError("会话标题不能为空")
|
||
session_row.title = cleaned
|
||
await self.db.commit()
|
||
return {"id": int(session_row.id), "title": cleaned}
|
||
|
||
async def delete_qa_session(self, *, session_id: int, user_id: int) -> bool:
|
||
session_row = (
|
||
await self.db.execute(
|
||
select(QASession).where(
|
||
and_(
|
||
QASession.id == int(session_id),
|
||
QASession.tenant_id == self.tenant_id,
|
||
QASession.user_id == int(user_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not session_row:
|
||
return False
|
||
|
||
qa_ids = (
|
||
await self.db.execute(
|
||
select(QARecord.id).where(
|
||
and_(
|
||
QARecord.tenant_id == self.tenant_id,
|
||
QARecord.session_id == int(session_id),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
id_list = [int(x) for x in qa_ids]
|
||
if id_list:
|
||
await self.db.execute(
|
||
delete(QAFeedback).where(
|
||
and_(
|
||
QAFeedback.tenant_id == self.tenant_id,
|
||
QAFeedback.qa_id.in_(id_list),
|
||
)
|
||
)
|
||
)
|
||
await self.db.execute(
|
||
delete(QARecord).where(
|
||
and_(
|
||
QARecord.tenant_id == self.tenant_id,
|
||
QARecord.session_id == int(session_id),
|
||
)
|
||
)
|
||
)
|
||
await self.db.delete(session_row)
|
||
await self.db.commit()
|
||
return True
|
||
|
||
async def submit_feedback(
|
||
self,
|
||
*,
|
||
qa_id: int,
|
||
rating: str,
|
||
submitted_by: Optional[int],
|
||
helpfulness: Optional[str] = None,
|
||
error_types: Optional[list[str]] = None,
|
||
correct_answer: Optional[str] = None,
|
||
) -> dict[str, Any]:
|
||
qa = (
|
||
await self.db.execute(
|
||
select(QARecord).where(
|
||
and_(QARecord.id == int(qa_id), QARecord.tenant_id == self.tenant_id)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not qa:
|
||
raise ValueError("qa record 不存在")
|
||
|
||
feedback = QAFeedback(
|
||
tenant_id=self.tenant_id,
|
||
qa_id=int(qa_id),
|
||
rating=(rating or "").strip().lower()[:16],
|
||
helpfulness=(helpfulness or "").strip()[:32] or None,
|
||
error_types=[str(x).strip() for x in (error_types or []) if str(x).strip()] or None,
|
||
correct_answer=(correct_answer or "").strip()[:4000] or None,
|
||
submitted_by=submitted_by,
|
||
)
|
||
self.db.add(feedback)
|
||
await self.db.commit()
|
||
await self.db.refresh(feedback)
|
||
if str(feedback.rating or "").strip().lower() == "negative" and (
|
||
str(feedback.correct_answer or "").strip() or [str(x).strip() for x in (feedback.error_types or []) if str(x).strip()]
|
||
):
|
||
try:
|
||
await self.upsert_intent_correction_sample(
|
||
user_utterance=str(qa.question or ""),
|
||
wrong_interpretation=str(qa.answer or ""),
|
||
correct_intent="query",
|
||
canonical_action="clarify_before_answer",
|
||
clarification_template=str(feedback.correct_answer or "").strip() or "收到纠正后先澄清范围再回答。",
|
||
risk_level="medium",
|
||
project_id=int(qa.project_id) if qa.project_id is not None else None,
|
||
)
|
||
except Exception:
|
||
await self.db.rollback()
|
||
return {
|
||
"id": int(feedback.id),
|
||
"qa_id": int(feedback.qa_id),
|
||
"rating": feedback.rating,
|
||
"helpfulness": feedback.helpfulness,
|
||
"error_types": feedback.error_types or [],
|
||
"correct_answer": feedback.correct_answer,
|
||
}
|
||
|
||
async def list_qa_records(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
date_from: Optional[datetime],
|
||
date_to: Optional[datetime],
|
||
rating: Optional[str],
|
||
page: int,
|
||
page_size: int,
|
||
) -> dict[str, Any]:
|
||
page = max(1, int(page))
|
||
page_size = max(1, min(100, int(page_size)))
|
||
date_from_naive = self._to_naive_utc(date_from)
|
||
date_to_naive = self._to_naive_utc(date_to)
|
||
|
||
stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id)
|
||
if project_id is not None:
|
||
stmt = stmt.where(QARecord.project_id == int(project_id))
|
||
if date_from_naive is not None:
|
||
stmt = stmt.where(QARecord.created_at >= date_from_naive)
|
||
if date_to_naive is not None:
|
||
stmt = stmt.where(QARecord.created_at <= date_to_naive)
|
||
|
||
records = (
|
||
await self.db.execute(
|
||
stmt.order_by(QARecord.created_at.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
|
||
ids = [int(r.id) for r in records]
|
||
feedback_rows = []
|
||
if ids:
|
||
fb_stmt = select(QAFeedback).where(
|
||
and_(
|
||
QAFeedback.tenant_id == self.tenant_id,
|
||
QAFeedback.qa_id.in_(ids),
|
||
)
|
||
)
|
||
if rating:
|
||
fb_stmt = fb_stmt.where(QAFeedback.rating == rating)
|
||
feedback_rows = (await self.db.execute(fb_stmt)).scalars().all()
|
||
|
||
latest_feedback: dict[int, QAFeedback] = {}
|
||
for fb in feedback_rows:
|
||
key = int(fb.qa_id)
|
||
curr = latest_feedback.get(key)
|
||
if curr is None or (fb.created_at and curr.created_at and fb.created_at > curr.created_at):
|
||
latest_feedback[key] = fb
|
||
|
||
items: list[dict[str, Any]] = []
|
||
for row in records:
|
||
fb = latest_feedback.get(int(row.id))
|
||
if rating and not fb:
|
||
continue
|
||
ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer)
|
||
items.append(
|
||
{
|
||
"id": int(row.id),
|
||
"question": row.question,
|
||
"answer": row.answer,
|
||
"sources": row.sources or [],
|
||
"intent": row.intent,
|
||
"domains_used": row.domains_used or [],
|
||
"response_time_ms": row.response_time_ms,
|
||
"asked_by": row.asked_by,
|
||
"asked_via": row.asked_via,
|
||
"project_id": row.project_id,
|
||
"session_id": row.session_id,
|
||
"turn_index": int(row.turn_index or 1),
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
"feedback": {
|
||
"id": int(fb.id),
|
||
"rating": fb.rating,
|
||
"helpfulness": fb.helpfulness,
|
||
"error_types": fb.error_types or [],
|
||
"correct_answer": fb.correct_answer,
|
||
}
|
||
if fb
|
||
else None,
|
||
**ack_meta,
|
||
}
|
||
)
|
||
|
||
return {
|
||
"items": items,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"count": len(items),
|
||
}
|
||
|
||
async def get_qa_stats(
|
||
self,
|
||
*,
|
||
project_id: Optional[int],
|
||
date_from: Optional[datetime],
|
||
date_to: Optional[datetime],
|
||
) -> dict[str, Any]:
|
||
date_from_naive = self._to_naive_utc(date_from)
|
||
date_to_naive = self._to_naive_utc(date_to)
|
||
record_stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id)
|
||
if project_id is not None:
|
||
record_stmt = record_stmt.where(QARecord.project_id == int(project_id))
|
||
if date_from_naive is not None:
|
||
record_stmt = record_stmt.where(QARecord.created_at >= date_from_naive)
|
||
if date_to_naive is not None:
|
||
record_stmt = record_stmt.where(QARecord.created_at <= date_to_naive)
|
||
|
||
records = (await self.db.execute(record_stmt)).scalars().all()
|
||
if not records:
|
||
return {
|
||
"total_sessions": 0,
|
||
"total_qa": 0,
|
||
"rated_count": 0,
|
||
"rated_ratio": 0.0,
|
||
"average_helpfulness": 0.0,
|
||
"exportable_pairs": 0,
|
||
}
|
||
|
||
record_ids = [int(r.id) for r in records]
|
||
feedback_rows = (
|
||
await self.db.execute(
|
||
select(QAFeedback).where(
|
||
and_(
|
||
QAFeedback.tenant_id == self.tenant_id,
|
||
QAFeedback.qa_id.in_(record_ids),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
|
||
latest_feedback: dict[int, QAFeedback] = {}
|
||
for fb in feedback_rows:
|
||
key = int(fb.qa_id)
|
||
curr = latest_feedback.get(key)
|
||
fb_at = fb.created_at or datetime.min
|
||
curr_at = curr.created_at if curr and curr.created_at else datetime.min
|
||
if curr is None or fb_at >= curr_at:
|
||
latest_feedback[key] = fb
|
||
|
||
rated_count = len(latest_feedback)
|
||
rated_ratio = round(rated_count / max(len(records), 1), 4)
|
||
|
||
helpful_scores: list[float] = []
|
||
helpful_map = {
|
||
"fully": 5.0,
|
||
"could_be_better": 4.0,
|
||
"partially": 3.0,
|
||
}
|
||
for fb in latest_feedback.values():
|
||
rating = str(fb.rating or "").strip().lower()
|
||
if rating == "positive":
|
||
score = helpful_map.get(str(fb.helpfulness or "").strip().lower(), 4.0)
|
||
elif rating == "negative":
|
||
score = 1.0
|
||
else:
|
||
continue
|
||
helpful_scores.append(score)
|
||
|
||
avg_helpfulness = round(sum(helpful_scores) / len(helpful_scores), 2) if helpful_scores else 0.0
|
||
|
||
exportable_pairs = 0
|
||
for fb in latest_feedback.values():
|
||
rating = str(fb.rating or "").strip().lower()
|
||
if rating == "positive" and str(fb.helpfulness or "").strip():
|
||
exportable_pairs += 1
|
||
continue
|
||
if rating == "negative":
|
||
exportable_pairs += 1
|
||
|
||
return {
|
||
"total_sessions": len({int(r.session_id) for r in records if r.session_id is not None}),
|
||
"total_qa": len(records),
|
||
"rated_count": rated_count,
|
||
"rated_ratio": rated_ratio,
|
||
"average_helpfulness": avg_helpfulness,
|
||
"exportable_pairs": exportable_pairs,
|
||
}
|
||
|
||
async def export_qa_jsonl(
|
||
self,
|
||
*,
|
||
include_unrated: bool,
|
||
project_id: Optional[int],
|
||
rating: Optional[str],
|
||
date_from: Optional[datetime],
|
||
date_to: Optional[datetime],
|
||
domain: Optional[str],
|
||
) -> str:
|
||
date_from_naive = self._to_naive_utc(date_from)
|
||
date_to_naive = self._to_naive_utc(date_to)
|
||
record_stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id)
|
||
if project_id is not None:
|
||
record_stmt = record_stmt.where(QARecord.project_id == int(project_id))
|
||
if date_from_naive is not None:
|
||
record_stmt = record_stmt.where(QARecord.created_at >= date_from_naive)
|
||
if date_to_naive is not None:
|
||
record_stmt = record_stmt.where(QARecord.created_at <= date_to_naive)
|
||
if domain:
|
||
record_stmt = record_stmt.where(QARecord.domains_used.is_not(None))
|
||
|
||
records = (await self.db.execute(record_stmt.order_by(QARecord.created_at.asc()))).scalars().all()
|
||
if not records:
|
||
return ""
|
||
|
||
feedback_map = await self._latest_feedback_map([int(r.id) for r in records])
|
||
session_ids = sorted({int(r.session_id) for r in records if r.session_id is not None})
|
||
session_map: dict[int, QASession] = {}
|
||
if session_ids:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(QASession).where(
|
||
and_(
|
||
QASession.tenant_id == self.tenant_id,
|
||
QASession.id.in_(session_ids),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
session_map = {int(x.id): x for x in rows}
|
||
|
||
lines: list[str] = []
|
||
normalized_rating = str(rating or "").strip().lower() or None
|
||
for record in records:
|
||
feedback = feedback_map.get(int(record.id))
|
||
if normalized_rating == "unrated":
|
||
if feedback:
|
||
continue
|
||
else:
|
||
if not feedback and not include_unrated:
|
||
continue
|
||
if normalized_rating in {"positive", "negative"}:
|
||
if not feedback or str(feedback.rating or "").strip().lower() != normalized_rating:
|
||
continue
|
||
if domain:
|
||
domains = [str(x).lower() for x in (record.domains_used or [])]
|
||
if domain.lower() not in domains:
|
||
continue
|
||
|
||
session = session_map.get(int(record.session_id)) if record.session_id is not None else None
|
||
row = {
|
||
"session_id": int(record.session_id) if record.session_id is not None else None,
|
||
"session_title": str(session.title or SESSION_DEFAULT_TITLE) if session else SESSION_DEFAULT_TITLE,
|
||
"turn_index": int(record.turn_index or 1),
|
||
"question": record.question,
|
||
"answer": record.answer,
|
||
"sources": record.sources or [],
|
||
"domains_used": record.domains_used or [],
|
||
"asked_via": record.asked_via,
|
||
"project_id": record.project_id,
|
||
"created_at": record.created_at.isoformat() if record.created_at else None,
|
||
# 兼容历史导出字段
|
||
"prompt": record.question,
|
||
"response": record.answer,
|
||
"rating": str(feedback.rating) if feedback else "unrated",
|
||
}
|
||
if feedback:
|
||
if feedback.helpfulness:
|
||
row["helpfulness"] = feedback.helpfulness
|
||
errors = [str(x) for x in (feedback.error_types or []) if str(x).strip()]
|
||
if errors:
|
||
row["error_type"] = errors[0]
|
||
row["error_types"] = errors
|
||
if feedback.correct_answer:
|
||
row["correct_answer"] = feedback.correct_answer
|
||
lines.append(json.dumps(row, ensure_ascii=False))
|
||
|
||
return "\n".join(lines)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Internal helpers
|
||
# ------------------------------------------------------------------
|
||
@staticmethod
|
||
def _contains_any(text: str, keywords: tuple[str, ...]) -> bool:
|
||
lowered = str(text or "").strip().lower()
|
||
if not lowered:
|
||
return False
|
||
return any(keyword in lowered for keyword in keywords)
|
||
|
||
@classmethod
|
||
def _is_correction_query(cls, question: str) -> bool:
|
||
text = str(question or "").strip().lower()
|
||
if not text:
|
||
return False
|
||
if BRIEF_NEGATIVE_FEEDBACK_RE.match(text):
|
||
return True
|
||
if cls._contains_any(text, CORRECTION_CUE_KEYWORDS):
|
||
return True
|
||
if re.search(r"(不是|别|不要).{0,8}(系统架构|架构)", text):
|
||
return True
|
||
return False
|
||
|
||
@classmethod
|
||
def _has_explicit_target_scope(cls, question: str) -> bool:
|
||
text = str(question or "").strip().lower()
|
||
if not text:
|
||
return False
|
||
explicit_groups: tuple[tuple[str, ...], ...] = (
|
||
ENGINE_PROCESS_KEYWORDS,
|
||
FEATURE_QUERY_KEYWORDS,
|
||
ARCHITECTURE_KEYWORDS,
|
||
PROJECT_KEYWORDS,
|
||
PEOPLE_KEYWORDS,
|
||
DOC_KEYWORDS,
|
||
)
|
||
for group in explicit_groups:
|
||
if cls._contains_any(text, group):
|
||
return True
|
||
if any(token in text for token in ("openclaw", "llm", "model", "engine", "api")):
|
||
return True
|
||
return False
|
||
|
||
@classmethod
|
||
def _is_engine_process_query(cls, question: str) -> bool:
|
||
return cls._contains_any(question, ENGINE_PROCESS_KEYWORDS)
|
||
|
||
@classmethod
|
||
def _is_explicit_deep_request(cls, question: str) -> bool:
|
||
return cls._contains_any(question, DEEP_EXPLICIT_KEYWORDS)
|
||
|
||
@classmethod
|
||
def _forced_deep_reason(
|
||
cls,
|
||
*,
|
||
question: str,
|
||
effective_question: str,
|
||
conversation: list[dict[str, str]],
|
||
) -> str:
|
||
if cls._is_correction_query(question) or cls._is_correction_query(effective_question):
|
||
if conversation:
|
||
return "correction_turn"
|
||
if cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question):
|
||
return "engine_process_query"
|
||
return ""
|
||
|
||
@classmethod
|
||
def _should_force_deep_for_channel(
|
||
cls,
|
||
*,
|
||
asked_via: str,
|
||
question: str,
|
||
effective_question: str,
|
||
reason: str,
|
||
) -> bool:
|
||
if not str(reason or "").strip():
|
||
return False
|
||
channel = str(asked_via or "").strip().lower()
|
||
if channel != "feishu":
|
||
return True
|
||
|
||
if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question):
|
||
return True
|
||
if str(reason or "").strip() == "engine_process_query":
|
||
combined = f"{question} {effective_question}".strip()
|
||
return cls._has_explicit_target_scope(combined)
|
||
return False
|
||
|
||
@classmethod
|
||
def _is_snapshot_only_sources(cls, sources: list[dict[str, Any]]) -> bool:
|
||
if not isinstance(sources, list) or not sources:
|
||
return True
|
||
allowed_labels = {"System Snapshot", "User Snapshot", "Session State"}
|
||
for row in sources:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
source = str(row.get("source") or "").strip().lower()
|
||
label = str(row.get("label") or "").strip()
|
||
if source != "system":
|
||
return False
|
||
if label and label not in allowed_labels:
|
||
return False
|
||
return True
|
||
|
||
@classmethod
|
||
def _should_auto_deep_retry(
|
||
cls,
|
||
*,
|
||
question: str,
|
||
effective_question: str,
|
||
sources: list[dict[str, Any]],
|
||
relevance_max: float,
|
||
) -> str:
|
||
process_sensitive = cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question)
|
||
correction_sensitive = cls._is_correction_query(question) or cls._is_correction_query(effective_question)
|
||
if not process_sensitive and not correction_sensitive:
|
||
return ""
|
||
snapshot_only = cls._is_snapshot_only_sources(sources)
|
||
if snapshot_only:
|
||
return "snapshot_only"
|
||
if float(relevance_max or 0.0) < 0.62:
|
||
return "low_relevance"
|
||
return ""
|
||
|
||
@classmethod
|
||
def _allow_auto_deep_retry_for_channel(
|
||
cls,
|
||
*,
|
||
asked_via: str,
|
||
question: str,
|
||
effective_question: str,
|
||
reason: str,
|
||
) -> bool:
|
||
if not str(reason or "").strip():
|
||
return False
|
||
channel = str(asked_via or "").strip().lower()
|
||
if channel != "feishu":
|
||
return True
|
||
if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question):
|
||
return True
|
||
return False
|
||
|
||
@classmethod
|
||
def _should_block_answer_with_guard(
|
||
cls,
|
||
*,
|
||
question: str,
|
||
effective_question: str,
|
||
sources: list[dict[str, Any]],
|
||
relevance_max: float,
|
||
) -> bool:
|
||
if not (cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question)):
|
||
return False
|
||
if not cls._is_snapshot_only_sources(sources):
|
||
return False
|
||
return float(relevance_max or 0.0) < 0.68
|
||
|
||
@staticmethod
|
||
def _build_evidence_guard_answer(question: str) -> str:
|
||
raw = str(question or "").strip()
|
||
target = raw[:80] if raw else "该问题"
|
||
return (
|
||
f"我不能对“{target}”直接给结论:当前只命中系统快照,缺少调用链证据。"
|
||
"请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。"
|
||
)
|
||
|
||
@staticmethod
|
||
def _is_snapshot_direct_question(question: str) -> bool:
|
||
text = str(question or "").strip().lower()
|
||
if not text:
|
||
return False
|
||
if AdaptiveRAGService._is_correction_query(text):
|
||
return False
|
||
if AdaptiveRAGService._is_engine_process_query(text):
|
||
return False
|
||
has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS)
|
||
ask_feature_purpose = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS)
|
||
return (
|
||
any(k in text for k in IDENTITY_KEYWORDS)
|
||
or any(k in text for k in ARCHITECTURE_KEYWORDS)
|
||
or ask_feature_purpose
|
||
)
|
||
|
||
@staticmethod
|
||
def _expand_followup_question(question: str, conversation: list[dict[str, str]]) -> str:
|
||
current = str(question or "").strip()
|
||
if not current:
|
||
return current
|
||
|
||
lowered = current.lower()
|
||
# 短句不等于追问:若已包含明确业务对象,则按独立问题处理,避免串错上下文。
|
||
has_explicit_subject = any(
|
||
marker in lowered for marker in ("项目", "信号", "信号源", "任务", "系统", "架构", "数据库", "redis", "接口", "用户")
|
||
)
|
||
is_short_followup = len(current) <= 16 and not has_explicit_subject
|
||
has_referential = any(k in lowered for k in FOLLOWUP_REFERENTIAL_KEYWORDS)
|
||
if not (is_short_followup or has_referential):
|
||
return current
|
||
|
||
anchor_question = ""
|
||
for turn in reversed(conversation[-5:]):
|
||
candidate = str(turn.get("question") or "").strip()
|
||
if not candidate:
|
||
continue
|
||
if candidate == current:
|
||
continue
|
||
if any(
|
||
marker in candidate
|
||
for marker in ("项目", "系统", "任务", "信号", "接口", "数据库", "redis", "架构", "模块")
|
||
):
|
||
anchor_question = candidate
|
||
break
|
||
|
||
if not anchor_question:
|
||
return current
|
||
|
||
is_enumeration = any(k in lowered for k in FOLLOWUP_ENUMERATION_KEYWORDS)
|
||
if is_enumeration and ("信号" in lowered or "信号" in anchor_question):
|
||
return f"{anchor_question}。请列出该项目关联的信号标题和ID。"
|
||
if is_enumeration and ("任务" in lowered or "任务" in anchor_question):
|
||
return f"{anchor_question}。请列出对应任务的标题和状态。"
|
||
if is_enumeration and "项目" in anchor_question:
|
||
return f"{anchor_question}。请列出对应项目的名称和ID。"
|
||
return f"{anchor_question}。补充问题:{current}"
|
||
|
||
def _build_snapshot_direct_answer(
|
||
self,
|
||
question: str,
|
||
*,
|
||
system_snapshot: Optional[dict[str, Any]],
|
||
user_snapshot: Optional[dict[str, Any]],
|
||
) -> Optional[str]:
|
||
text = str(question or "").strip().lower()
|
||
if not text:
|
||
return None
|
||
if self._is_correction_query(text):
|
||
return None
|
||
if self._is_engine_process_query(text):
|
||
return None
|
||
|
||
ask_identity = any(k in text for k in IDENTITY_KEYWORDS)
|
||
ask_architecture = any(k in text for k in ARCHITECTURE_KEYWORDS)
|
||
has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS)
|
||
ask_feature = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS)
|
||
if not ask_identity and not ask_architecture and not ask_feature:
|
||
return None
|
||
|
||
system_data = system_snapshot if isinstance(system_snapshot, dict) else {}
|
||
assistant = system_data.get("assistant_profile") if isinstance(system_data.get("assistant_profile"), dict) else {}
|
||
platform = system_data.get("platform_profile") if isinstance(system_data.get("platform_profile"), dict) else {}
|
||
backend = platform.get("backend") if isinstance(platform.get("backend"), dict) else {}
|
||
infra = platform.get("infrastructure") if isinstance(platform.get("infrastructure"), dict) else {}
|
||
module_counts = backend.get("module_counts") if isinstance(backend.get("module_counts"), dict) else {}
|
||
|
||
lines: list[str] = []
|
||
if ask_identity:
|
||
name = str(assistant.get("name") or "PMFlow AI 管家")
|
||
identity = str(assistant.get("identity") or "PMFlow 内置 AI 助手")
|
||
lines.append(f"我是{name},{identity}。")
|
||
responsibilities = assistant.get("responsibilities") if isinstance(assistant.get("responsibilities"), list) else []
|
||
if responsibilities:
|
||
joined = ";".join(str(x) for x in responsibilities[:5] if str(x).strip())
|
||
if joined:
|
||
lines.append(f"职责范围:{joined}。")
|
||
|
||
if ask_architecture:
|
||
runtime = str(platform.get("runtime") or "unknown")
|
||
backend_framework = str(backend.get("framework") or "FastAPI")
|
||
orm_name = str(backend.get("orm") or "SQLAlchemy 2.0")
|
||
db_name = str(infra.get("database") or "PostgreSQL")
|
||
cache_name = str(infra.get("cache") or "Redis")
|
||
lines.append(
|
||
f"当前系统采用前后端分层架构:后端 {backend_framework} + {orm_name},数据库 {db_name},缓存 {cache_name},运行环境 {runtime}。"
|
||
)
|
||
if module_counts:
|
||
lines.append(
|
||
"后端模块规模:"
|
||
f"api {int(module_counts.get('api', 0))}、"
|
||
f"services {int(module_counts.get('services', 0))}、"
|
||
f"models {int(module_counts.get('models', 0))}、"
|
||
f"schemas {int(module_counts.get('schemas', 0))}。"
|
||
)
|
||
|
||
if ask_feature:
|
||
feature_hits = self._match_feature_catalog_entries(
|
||
question=question,
|
||
system_snapshot=system_data,
|
||
limit=4,
|
||
)
|
||
if feature_hits:
|
||
lines.append("命中的产品功能:")
|
||
for idx, row in enumerate(feature_hits, start=1):
|
||
name = str(row.get("name") or "未知功能")
|
||
summary = str(row.get("summary") or "暂无说明")
|
||
route = str(row.get("route") or "")
|
||
page_file = str(row.get("page_file") or "")
|
||
apis = row.get("apis") if isinstance(row.get("apis"), list) else []
|
||
api_text = "、".join(str(x) for x in apis[:3] if str(x).strip())
|
||
line = f"{idx}. {name}:{summary}"
|
||
if route:
|
||
line += f" 入口 {route}。"
|
||
if page_file:
|
||
line += f" 页面文件 {page_file}。"
|
||
if api_text:
|
||
line += f" 相关接口 {api_text}。"
|
||
lines.append(line)
|
||
else:
|
||
catalog = self._feature_catalog_from_snapshot(system_data)
|
||
if catalog:
|
||
brief = "、".join(
|
||
str(row.get("name") or "")
|
||
for row in catalog[:8]
|
||
if isinstance(row, dict) and str(row.get("name") or "").strip()
|
||
)
|
||
if brief:
|
||
lines.append(f"当前功能目录包括:{brief}。你可以继续指定其中一个功能名称,我会返回用途与入口。")
|
||
else:
|
||
lines.append("当前 System Snapshot 未包含 feature_catalog,建议先刷新上下文缓存后再提问。")
|
||
|
||
if ask_identity and isinstance(user_snapshot, dict) and user_snapshot:
|
||
projects = user_snapshot.get("projects") if isinstance(user_snapshot.get("projects"), list) else []
|
||
tasks = user_snapshot.get("assigned_tasks") if isinstance(user_snapshot.get("assigned_tasks"), list) else []
|
||
lines.append(f"你的当前上下文:关联项目 {len(projects)} 个,未完成任务 {len(tasks)} 个。")
|
||
|
||
if not lines:
|
||
return None
|
||
lines.append("来源: System Snapshot / User Snapshot")
|
||
return "\n".join(lines)[:3200]
|
||
|
||
async def _generate_answer(
|
||
self,
|
||
question: str,
|
||
retrieved: list[RetrievedItem],
|
||
conversation: list[dict[str, str]],
|
||
project_id: Optional[int],
|
||
*,
|
||
system_snapshot: Optional[dict[str, Any]] = None,
|
||
user_snapshot: Optional[dict[str, Any]] = None,
|
||
relevance_max: float = 0.0,
|
||
thinking_mode: str = THINKING_MODE_NORMAL,
|
||
context_block_limit: Optional[int] = None,
|
||
answer_max_tokens: Optional[int] = None,
|
||
) -> str:
|
||
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
||
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
||
context_blocks: list[str] = []
|
||
used_tokens = 0
|
||
max_blocks = max(4, min(int(context_block_limit or DEFAULT_TOP_K * 2), 32))
|
||
manifest = await self.get_project_manifest(project_id=project_id)
|
||
manifest_text = str(manifest.get("manifest_text") or "").strip()
|
||
if manifest_text:
|
||
manifest_block = f"[M] 项目画像\n{manifest_text}\n"
|
||
manifest_tokens = max(len(manifest_block) // 4, 1)
|
||
if used_tokens + manifest_tokens <= MAX_CONTEXT_TOKENS:
|
||
context_blocks.append(manifest_block)
|
||
used_tokens += manifest_tokens
|
||
|
||
for idx, item in enumerate(retrieved[:max_blocks]):
|
||
block = f"[{idx + 1}] 来源: {item.source_label}\n相关度: {item.score:.3f}\n内容: {item.content}\n"
|
||
block_tokens = max(len(block) // 4, 1)
|
||
if used_tokens + block_tokens > MAX_CONTEXT_TOKENS:
|
||
break
|
||
used_tokens += block_tokens
|
||
context_blocks.append(block)
|
||
|
||
history_lines = []
|
||
for row in conversation[-10:]:
|
||
history_lines.append(f"Q: {row.get('question', '')}\nA: {row.get('answer', '')}")
|
||
|
||
system_snapshot_text = json.dumps(system_snapshot or {}, ensure_ascii=False)[:12000]
|
||
user_snapshot_text = json.dumps(user_snapshot or {}, ensure_ascii=False)[:6000]
|
||
|
||
prompt = PROMPT_RAG_ANSWER.format(
|
||
project_id=project_id if project_id is not None else "unknown",
|
||
question=question,
|
||
conversation=chr(10).join(history_lines) if history_lines else "无",
|
||
context_blocks=chr(10).join(context_blocks) if context_blocks else "无",
|
||
system_snapshot=system_snapshot_text or "{}",
|
||
user_snapshot=user_snapshot_text or "{}",
|
||
relevance_max=f"{max(0.0, float(relevance_max or 0.0)):.3f}",
|
||
).strip()
|
||
if deep_thinking:
|
||
prompt = (
|
||
f"{prompt}\n\n"
|
||
"补充要求(深入思考模式):\n"
|
||
"1. 先给结论,再给证据链;\n"
|
||
"2. 明确区分已验证事实与推断;\n"
|
||
"3. 若存在不确定项,给出待确认清单。"
|
||
)
|
||
|
||
effective_max_tokens = int(
|
||
answer_max_tokens
|
||
if answer_max_tokens is not None
|
||
else (DEEP_ANSWER_MAX_TOKENS if deep_thinking else 900)
|
||
)
|
||
effective_max_tokens = max(256, min(effective_max_tokens, 3000))
|
||
effective_temperature = 0.15 if deep_thinking else 0.2
|
||
function_name = "adaptive_rag_answer_deep" if deep_thinking else "adaptive_rag_answer_normal"
|
||
|
||
answer = await self.llm.generate(
|
||
prompt,
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
function_name=function_name,
|
||
temperature=effective_temperature,
|
||
max_tokens=effective_max_tokens,
|
||
use_prompt_profile=False,
|
||
)
|
||
text = str(answer or "").strip()
|
||
if not text:
|
||
return "我未能从当前上下文生成有效答案,请补充问题细节后重试。"
|
||
enforced = self._enforce_feature_evidence_in_answer(
|
||
question=question,
|
||
answer_text=text,
|
||
retrieved=retrieved,
|
||
system_snapshot=system_snapshot,
|
||
)
|
||
return str(enforced or text)[:3200]
|
||
|
||
async def _load_recent_conversation(
|
||
self,
|
||
*,
|
||
asked_by: Optional[int],
|
||
asked_via: str,
|
||
project_id: Optional[int],
|
||
session_id: Optional[int],
|
||
external_session_id: Optional[int],
|
||
limit: int,
|
||
) -> list[dict[str, str]]:
|
||
active_session_id = session_id if session_id is not None else external_session_id
|
||
if active_session_id is not None:
|
||
cached_messages = await self.context_cache.get_session_messages(
|
||
tenant_id=self.tenant_id,
|
||
session_id=active_session_id,
|
||
)
|
||
if cached_messages:
|
||
cached_turns = self.context_cache.messages_to_turns(cached_messages)
|
||
if cached_turns:
|
||
return cached_turns[-limit:]
|
||
|
||
if asked_by is None:
|
||
return []
|
||
|
||
stmt = select(QARecord).where(
|
||
and_(
|
||
QARecord.tenant_id == self.tenant_id,
|
||
QARecord.asked_by == int(asked_by),
|
||
QARecord.asked_via == (asked_via or "pmflow")[:20],
|
||
)
|
||
)
|
||
if session_id is not None:
|
||
stmt = stmt.where(QARecord.session_id == int(session_id))
|
||
if project_id is not None:
|
||
stmt = stmt.where(QARecord.project_id == int(project_id))
|
||
rows = (await self.db.execute(stmt.order_by(QARecord.created_at.desc()).limit(limit))).scalars().all()
|
||
return [{"question": str(x.question or ""), "answer": str(x.answer or "")} for x in reversed(rows)]
|
||
|
||
def _map_domains_to_source_types(self, domains: list[str]) -> list[str]:
|
||
mapped: list[str] = []
|
||
if "doc" in domains:
|
||
mapped.append("doc")
|
||
if "feishu" in domains:
|
||
mapped.append("feishu")
|
||
if "pmflow" in domains:
|
||
mapped.append("pmflow")
|
||
if "url" in domains:
|
||
mapped.append("url")
|
||
return mapped
|
||
|
||
def _source_label(self, source_type: str, metadata: dict[str, Any]) -> str:
|
||
if source_type == "doc":
|
||
title = metadata.get("title") or "unknown"
|
||
page_number = metadata.get("page_number")
|
||
if page_number is not None:
|
||
return f"Document[{title}] p.{page_number}"
|
||
return f"Document[{title}]"
|
||
if source_type == "url":
|
||
return f"URL[{metadata.get('file_url') or metadata.get('url') or 'external'}]"
|
||
if source_type == "feishu":
|
||
return "Feishu history"
|
||
if source_type == "pmflow":
|
||
record_type = str(metadata.get("record_type") or "").strip()
|
||
record_id = metadata.get("record_id")
|
||
if record_type and record_id is not None:
|
||
return f"PMFlow[{record_type}#{record_id}]"
|
||
return "PMFlow records"
|
||
return "Knowledge base"
|
||
|
||
async def _noop_results(self) -> list[RetrievedItem]:
|
||
return []
|
||
|
||
async def _fetch_url_text(self, url: str) -> str:
|
||
target = str(url or "").strip()
|
||
if not target:
|
||
raise ValueError("url_empty")
|
||
async with httpx.AsyncClient(timeout=20.0, trust_env=False, follow_redirects=True) as client:
|
||
resp = await client.get(target, headers={"User-Agent": "PMFlow-RAG/1.0"})
|
||
resp.raise_for_status()
|
||
content_type = str(resp.headers.get("content-type") or "").lower()
|
||
body = resp.text
|
||
if "html" in content_type:
|
||
return self._html_to_text(body)
|
||
return body[:300000]
|
||
|
||
@staticmethod
|
||
def _html_to_text(html: str) -> str:
|
||
text = str(html or "")
|
||
text = re.sub(r"<script.*?>.*?</script>", " ", text, flags=re.IGNORECASE | re.DOTALL)
|
||
text = re.sub(r"<style.*?>.*?</style>", " ", text, flags=re.IGNORECASE | re.DOTALL)
|
||
text = re.sub(r"<[^>]+>", " ", text)
|
||
text = re.sub(r" | ", " ", text)
|
||
text = re.sub(r"&", "&", text)
|
||
text = re.sub(r"<", "<", text)
|
||
text = re.sub(r">", ">", text)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
return text[:300000]
|
||
|
||
def _chunk_text(self, text: str, *, chunk_size: int = 500, overlap: int = 50) -> list[str]:
|
||
clean = str(text or "").strip()
|
||
if not clean:
|
||
return []
|
||
if len(clean) <= chunk_size:
|
||
return [clean]
|
||
|
||
chunks: list[str] = []
|
||
start = 0
|
||
step = max(1, chunk_size - overlap)
|
||
while start < len(clean):
|
||
end = min(len(clean), start + chunk_size)
|
||
part = clean[start:end].strip()
|
||
if part:
|
||
chunks.append(part)
|
||
if end >= len(clean):
|
||
break
|
||
start += step
|
||
return chunks
|
||
|
||
def _chunk_text_with_metadata(
|
||
self,
|
||
text: str,
|
||
*,
|
||
chunk_size: int = 500,
|
||
overlap: int = 50,
|
||
) -> list[tuple[str, dict[str, Any]]]:
|
||
segments = self._split_text_by_page_markers(text)
|
||
if not segments:
|
||
return [(part, {}) for part in self._chunk_text(text, chunk_size=chunk_size, overlap=overlap)]
|
||
|
||
out: list[tuple[str, dict[str, Any]]] = []
|
||
for page_number, segment in segments:
|
||
for part in self._chunk_text(segment, chunk_size=chunk_size, overlap=overlap):
|
||
meta: dict[str, Any] = {}
|
||
if page_number is not None:
|
||
meta["page_number"] = int(page_number)
|
||
out.append((part, meta))
|
||
return out
|
||
|
||
def _split_text_by_page_markers(self, text: str) -> list[tuple[Optional[int], str]]:
|
||
clean = str(text or "")
|
||
matches = list(PAGE_MARKER_RE.finditer(clean))
|
||
if not matches:
|
||
return []
|
||
|
||
segments: list[tuple[Optional[int], str]] = []
|
||
prefix = clean[: matches[0].start()].strip()
|
||
if prefix:
|
||
segments.append((None, prefix))
|
||
|
||
for idx, marker in enumerate(matches):
|
||
try:
|
||
page_number = int(marker.group(1))
|
||
except Exception:
|
||
page_number = None
|
||
start = marker.end()
|
||
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(clean)
|
||
segment = clean[start:end].strip()
|
||
if segment:
|
||
segments.append((page_number, segment))
|
||
return segments
|
||
|
||
@staticmethod
|
||
def _to_float(value: Any, default: float) -> float:
|
||
try:
|
||
return float(value)
|
||
except Exception:
|
||
return float(default)
|
||
|
||
@staticmethod
|
||
def _safe_dict(value: Any) -> dict[str, Any]:
|
||
if isinstance(value, dict):
|
||
return value
|
||
if isinstance(value, str):
|
||
try:
|
||
parsed = json.loads(value)
|
||
return parsed if isinstance(parsed, dict) else {}
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
|
||
@staticmethod
|
||
def _parse_json_object(raw: str) -> dict[str, Any]:
|
||
text = str(raw or "").strip()
|
||
if text.startswith("```"):
|
||
parts = text.split("```")
|
||
if len(parts) >= 2:
|
||
text = parts[1].strip()
|
||
if text.startswith("json"):
|
||
text = text[4:].strip()
|
||
parsed: dict[str, Any] = {}
|
||
try:
|
||
value = json.loads(text)
|
||
if isinstance(value, dict):
|
||
parsed = value
|
||
except Exception:
|
||
pass
|
||
return parsed
|
||
|
||
@staticmethod
|
||
def _embedding_to_list(raw: Any) -> list[float]:
|
||
if raw is None:
|
||
return []
|
||
if isinstance(raw, list):
|
||
return [float(x) for x in raw]
|
||
if isinstance(raw, str):
|
||
try:
|
||
value = json.loads(raw)
|
||
if isinstance(value, list):
|
||
return [float(x) for x in value]
|
||
except Exception:
|
||
return []
|
||
return []
|
||
|
||
@staticmethod
|
||
def _cosine_similarity(v1: list[float], v2: list[float]) -> float:
|
||
if not v1 or not v2:
|
||
return 0.0
|
||
size = min(len(v1), len(v2))
|
||
if size == 0:
|
||
return 0.0
|
||
a = v1[:size]
|
||
b = v2[:size]
|
||
dot = sum(x * y for x, y in zip(a, b))
|
||
norm_a = math.sqrt(sum(x * x for x in a))
|
||
norm_b = math.sqrt(sum(y * y for y in b))
|
||
if norm_a == 0 or norm_b == 0:
|
||
return 0.0
|
||
sim = dot / (norm_a * norm_b)
|
||
return (sim + 1) / 2
|
||
|
||
def _dedupe_sources(self, retrieved: list[RetrievedItem]) -> list[dict[str, Any]]:
|
||
seen: set[str] = set()
|
||
out: list[dict[str, Any]] = []
|
||
for item in retrieved:
|
||
meta_identity = (
|
||
item.metadata.get("message_id")
|
||
or item.metadata.get("file_url")
|
||
or item.metadata.get("target_url")
|
||
or item.metadata.get("path")
|
||
or item.metadata.get("relation_id")
|
||
or item.metadata.get("entity_id")
|
||
or item.metadata.get("subject_entity_id")
|
||
or item.metadata.get("object_entity_id")
|
||
or item.content[:80]
|
||
)
|
||
key = f"{item.source}|{item.source_label}|{meta_identity}"
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append(
|
||
{
|
||
"source": item.source,
|
||
"label": item.source_label,
|
||
"score": round(item.score, 4),
|
||
"metadata": item.metadata,
|
||
}
|
||
)
|
||
if len(out) >= 8:
|
||
break
|
||
return out
|
||
|
||
@staticmethod
|
||
def _build_pmflow_target_url(*, record_type: str, record_id: Any, project_id: Any) -> str:
|
||
safe_type = str(record_type or "").strip().lower()
|
||
try:
|
||
pid = int(project_id) if project_id is not None else None
|
||
except Exception:
|
||
pid = None
|
||
try:
|
||
rid = int(record_id) if record_id is not None else None
|
||
except Exception:
|
||
rid = None
|
||
if pid is None:
|
||
return ""
|
||
if safe_type == "charter" and rid is not None:
|
||
return f"/projects/{pid}/charter/{rid}"
|
||
if safe_type == "task" and rid is not None:
|
||
return f"/projects/{pid}/board?focus=task&taskId={rid}"
|
||
if safe_type == "review" and rid is not None:
|
||
return f"/projects/{pid}/board?focus=review&reviewId={rid}"
|
||
return f"/projects/{pid}/board"
|
||
|
||
@staticmethod
|
||
def _serialize_doc(doc: KnowledgeBaseDoc) -> dict[str, Any]:
|
||
return {
|
||
"id": int(doc.id),
|
||
"title": doc.title,
|
||
"file_type": doc.file_type,
|
||
"source_type": doc.source_type,
|
||
"file_url": doc.file_url,
|
||
"status": doc.status,
|
||
"uploaded_by": doc.uploaded_by,
|
||
"created_at": doc.created_at.isoformat() if doc.created_at else None,
|
||
"last_fetched_at": doc.last_fetched_at.isoformat() if doc.last_fetched_at else None,
|
||
"error_message": doc.error_message,
|
||
}
|
||
|
||
|
||
__all__ = ["AdaptiveRAGService"]
|