PMFlow/backend/app/services/adaptive_rag_service.py

6737 lines
262 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""全自适应 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"&nbsp;|&#160;", " ", text)
text = re.sub(r"&amp;", "&", text)
text = re.sub(r"&lt;", "<", text)
text = re.sub(r"&gt;", ">", 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"]