226 lines
8.8 KiB
Python
226 lines
8.8 KiB
Python
"""Adaptive RAG mixin module."""
|
||
from __future__ import annotations
|
||
|
||
from .shared import *
|
||
|
||
|
||
class AdaptiveRAGSessionStateMixin:
|
||
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": now_cn_aware().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,
|
||
}
|
||
|