PMFlow/backend/app/services/adaptive_rag_service/mixin_session_state.py

655 lines
28 KiB
Python
Raw Permalink 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.

"""Adaptive RAG mixin module."""
from __future__ import annotations
from app.schemas.context_workspace import (
ContextCapsule,
ResearchWorkspace,
normalize_context_capsules_payload,
normalize_research_workspace_payload,
)
from .shared import *
class AdaptiveRAGSessionStateMixin:
@staticmethod
def _normalize_active_workspace(payload: Any) -> dict[str, Any]:
return normalize_research_workspace_payload(payload)
@staticmethod
def _normalize_context_capsules(payload: Any) -> list[dict[str, Any]]:
return normalize_context_capsules_payload(payload)
@staticmethod
def _normalize_state_scope(payload: Optional[dict[str, Any]]) -> dict[str, Any]:
row = payload if isinstance(payload, dict) else {}
project_id = row.get("project_id")
try:
normalized_project_id = int(project_id) if project_id is not None else None
except Exception:
normalized_project_id = None
target_ref = str(row.get("target_ref") or "").strip() or None
target_entity_type = str(row.get("target_entity_type") or "").strip().lower() or None
return {
"project_id": normalized_project_id,
"target_ref": target_ref,
"target_entity_type": target_entity_type,
}
def _state_scope_from_qa_record(self, qa_record: Optional[QARecord]) -> dict[str, Any]:
if qa_record is None:
return self._normalize_state_scope(None)
snapshot = qa_record.routing_snapshot if isinstance(qa_record.routing_snapshot, dict) else {}
query_semantics = snapshot.get("query_semantics") if isinstance(snapshot.get("query_semantics"), dict) else {}
response_payload = snapshot.get("response_payload") if isinstance(snapshot.get("response_payload"), dict) else {}
target_resolution = (
query_semantics.get("target_resolution")
if isinstance(query_semantics.get("target_resolution"), dict)
else {}
)
customer_interaction_context = (
response_payload.get("customer_interaction_context_packet")
if isinstance(response_payload.get("customer_interaction_context_packet"), dict)
else {}
)
current_focus_object = (
customer_interaction_context.get("current_focus_object")
if isinstance(customer_interaction_context.get("current_focus_object"), dict)
else {}
)
analysis_packet = (
response_payload.get("analysis_packet")
if isinstance(response_payload.get("analysis_packet"), dict)
else {}
)
fallback_target_ref = ""
fallback_target_entity_type = ""
for row in list(analysis_packet.get("focus_objects") or []):
if not isinstance(row, dict):
continue
fallback_target_ref = str(row.get("object_ref") or row.get("target_ref") or "").strip()
fallback_target_entity_type = str(row.get("object_type") or row.get("target_entity_type") or "").strip().lower()
if fallback_target_ref:
break
project_id = qa_record.project_id
if project_id is None:
try:
project_id = int(current_focus_object.get("object_id")) if current_focus_object.get("object_id") is not None else None
except Exception:
project_id = None
target_ref = (
str(current_focus_object.get("object_ref") or "").strip()
or str(target_resolution.get("target_ref") or "").strip()
or fallback_target_ref
or None
)
if project_id is None and target_ref and str(target_ref).startswith("project:"):
try:
project_id = int(str(target_ref).split(":", 1)[1])
except Exception:
project_id = None
return self._normalize_state_scope(
{
"project_id": project_id,
"target_ref": target_ref,
"target_entity_type": (
str(current_focus_object.get("object_type") or "").strip().lower()
or
str(target_resolution.get("target_entity_type") or "").strip().lower()
or fallback_target_entity_type
or None
),
}
)
async def _load_last_turn_scope(self, *, session_id: Optional[int]) -> dict[str, Any]:
if session_id is None:
return self._normalize_state_scope(None)
row = (
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.desc(), QARecord.id.desc())
.limit(1)
)
).scalar_one_or_none()
return self._state_scope_from_qa_record(row)
async def _resolve_current_turn_scope(
self,
*,
question: str,
project_id: Optional[int],
source_policy_hint: Optional[str],
query_semantics: Optional[dict[str, Any]],
) -> dict[str, Any]:
semantics = dict(query_semantics or {}) if isinstance(query_semantics, dict) else {}
if not semantics:
resolver = getattr(self, "_resolve_query_semantics", None)
if callable(resolver):
try:
semantics = await resolver(
message=question,
source_system="adaptive_rag_state_rewrite_gate",
source_policy_hint=source_policy_hint,
)
except Exception:
semantics = {}
scoped_project_id = project_id
scope_resolver = getattr(self, "_resolve_project_scope_for_question", None)
if callable(scope_resolver):
try:
scoped_project_id, _, _ = await scope_resolver(
question=question,
project_id=project_id,
query_semantics=semantics,
internal_context_packet=None,
)
except TypeError:
scoped_project_id, _, _ = await scope_resolver(
question=question,
project_id=project_id,
query_semantics=semantics,
)
except Exception:
scoped_project_id = project_id
target_resolution = (
semantics.get("target_resolution")
if isinstance(semantics.get("target_resolution"), dict)
else {}
)
target_ref = str(target_resolution.get("target_ref") or "").strip() or None
if scoped_project_id is None and target_ref and target_ref.startswith("project:"):
try:
scoped_project_id = int(target_ref.split(":", 1)[1])
except Exception:
scoped_project_id = None
return self._normalize_state_scope(
{
"project_id": scoped_project_id,
"target_ref": target_ref,
"target_entity_type": str(target_resolution.get("target_entity_type") or "").strip().lower() or None,
}
)
async def _build_state_rewrite_governance(
self,
*,
question: str,
project_id: Optional[int],
session_id: Optional[int],
source_policy_hint: Optional[str],
query_semantics: Optional[dict[str, Any]],
session_state: Optional[QASessionState],
anchor_resolution: Optional[FollowupAnchorResolution],
) -> dict[str, Any]:
if anchor_resolution is None:
resolution = type(
"_FollowupResolution",
(),
{"followup_mode": "none", "anchor_mode": "none"},
)()
else:
resolution = anchor_resolution
followup_mode = str(resolution.followup_mode or "").strip().lower()
anchor_mode = str(resolution.anchor_mode or "").strip().lower()
governance: dict[str, Any] = {
"allow_state_rewrite": True,
"reason": "followup_scope_continuity",
"previous_scope": self._normalize_state_scope(None),
"current_scope": self._normalize_state_scope(None),
}
if followup_mode != "referential":
governance["reason"] = "non_referential_turn"
return governance
if anchor_mode == "explicit":
governance["reason"] = "explicit_anchor"
return governance
if session_state is None or session_id is None:
governance["reason"] = "missing_session_state"
return governance
previous_scope = await self._load_last_turn_scope(session_id=session_id)
current_scope = await self._resolve_current_turn_scope(
question=question,
project_id=project_id,
source_policy_hint=source_policy_hint,
query_semantics=query_semantics,
)
governance["previous_scope"] = previous_scope
governance["current_scope"] = current_scope
previous_project_id = previous_scope.get("project_id")
current_project_id = current_scope.get("project_id")
previous_target_ref = str(previous_scope.get("target_ref") or "").strip() or None
current_target_ref = str(current_scope.get("target_ref") or "").strip() or None
if current_project_id is not None and previous_project_id is not None and current_project_id != previous_project_id:
governance["allow_state_rewrite"] = False
governance["reason"] = "new_project_scope"
return governance
if current_project_id is not None and previous_project_id is None:
governance["allow_state_rewrite"] = False
governance["reason"] = "new_project_scope"
return governance
if current_project_id is not None and previous_project_id == current_project_id and current_target_ref and previous_target_ref and current_target_ref != previous_target_ref:
governance["allow_state_rewrite"] = False
governance["reason"] = "new_business_scope"
return governance
return governance
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=[],
active_workspace=None,
context_capsules=[],
pending_clarification=None,
pending_clarification_expires_at=None,
)
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],
anchor_resolution: Optional[FollowupAnchorResolution] = None,
rewrite_governance: Optional[dict[str, Any]] = None,
) -> tuple[str, dict[str, Any]]:
_ = conversation
current = str(question or "").strip()
if not current:
return current, {"mode": "empty"}
if anchor_resolution is None:
resolution = type(
"_FollowupResolution",
(),
{"followup_mode": "none", "anchor_mode": "none"},
)()
else:
resolution = anchor_resolution
if str(resolution.followup_mode or "").strip().lower() != "referential":
return current, {"mode": "direct"}
governance = rewrite_governance if isinstance(rewrite_governance, dict) else {}
if governance and governance.get("allow_state_rewrite") is False:
return current, {
"mode": "scope_reset",
"reason": str(governance.get("reason") or "scope_changed").strip() or "scope_changed",
"current_scope": self._normalize_state_scope(governance.get("current_scope")),
"previous_scope": self._normalize_state_scope(governance.get("previous_scope")),
}
explicit_anchor_mode = str(resolution.anchor_mode or "").strip().lower()
if explicit_anchor_mode == "explicit":
anchored_question, anchor_meta = self._rewrite_question_with_followup_anchor(
question=current,
anchor_resolution=resolution,
)
if str(anchor_meta.get("mode") or "").strip() not in {"", "direct"}:
return anchored_question, anchor_meta
state_subject = ""
state_entity_ids: list[int] = []
if session_state is not None:
active_workspace = self._normalize_active_workspace(getattr(session_state, "active_workspace", None))
if active_workspace:
primary_title = str(active_workspace.get("primary_object_title") or "").strip()
workspace_topic = str(active_workspace.get("topic") or "").strip()
candidate_names = [
str(item.get("display_name") or "").strip()
for item in list(active_workspace.get("candidate_items") or [])[:3]
if isinstance(item, dict) and str(item.get("display_name") or "").strip()
]
scope_bits = [bit for bit in [primary_title, workspace_topic] if bit]
if candidate_names:
scope_bits.append("候选对象:" + "、".join(candidate_names))
if scope_bits:
rewritten = f"围绕当前研究工作区({';'.join(scope_bits[:3])})回答:{current}"
return rewritten[:1200], {
"mode": "active_workspace",
"workspace_id": str(active_workspace.get("workspace_id") or "").strip() or None,
"workspace_topic": workspace_topic or None,
"primary_object_ref": str(active_workspace.get("primary_object_ref") or "").strip() or None,
"candidate_set_binding": str(active_workspace.get("candidate_set_binding") or "").strip() or 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]}
anchored_question, anchor_meta = self._rewrite_question_with_followup_anchor(
question=current,
anchor_resolution=resolution,
)
if str(anchor_meta.get("mode") or "").strip() not in {"", "direct"}:
return anchored_question, anchor_meta
return current, {"mode": "referential_without_subject"}
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]],
response_payload: Optional[dict[str, Any]] = None,
route_decision: Optional[dict[str, Any]] = None,
) -> None:
if session_state is None:
return
session_state.last_intent = "question"
session_state.ambiguity = str(ambiguity or "clear")[:20]
if str(ambiguity or "").strip() != "needs_clarification":
next_workspace = self._workspace_from_turn(
response_payload=response_payload,
route_decision=route_decision,
question=question,
answer=answer,
sources=sources,
)
if next_workspace:
session_state.active_workspace = next_workspace
session_state.context_capsules = self._merge_context_capsules(
existing=getattr(session_state, "context_capsules", None),
workspace=next_workspace,
)
subject = self._extract_state_subject(question=question, answer=answer, sources=sources)
if not subject and next_workspace:
subject = (
str(next_workspace.get("primary_object_title") or "").strip()
or str(next_workspace.get("topic") or "").strip()
)
if subject:
session_state.last_subject = subject[:255]
collected_entity_ids = self._collect_entity_ids_from_sources(sources)
if not collected_entity_ids and next_workspace:
workspace_project_id = next_workspace.get("primary_object_id")
try:
if workspace_project_id is not None:
collected_entity_ids = [int(workspace_project_id)]
except Exception:
collected_entity_ids = []
if collected_entity_ids:
session_state.last_entity_ids = collected_entity_ids
self._clear_pending_clarification(session_state=session_state)
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 _workspace_from_turn(
self,
*,
response_payload: Optional[dict[str, Any]],
route_decision: Optional[dict[str, Any]],
question: str,
answer: str,
sources: list[dict[str, Any]],
) -> dict[str, Any]:
payload = response_payload if isinstance(response_payload, dict) else {}
workspace = self._normalize_active_workspace(payload.get("research_workspace"))
if workspace:
return workspace
normalized_route = route_decision if isinstance(route_decision, dict) else {}
effective_source_policy = str(
normalized_route.get("effective_source_policy") or normalized_route.get("route_mode") or ""
).strip().lower()
interaction_context = (
dict(payload.get("customer_interaction_context_packet") or {})
if isinstance(payload.get("customer_interaction_context_packet"), dict)
else {}
)
current_focus = (
dict(interaction_context.get("current_focus_object") or {})
if isinstance(interaction_context.get("current_focus_object"), dict)
else {}
)
if effective_source_policy not in {"external", "hybrid"}:
return {}
object_ref = str(current_focus.get("object_ref") or "").strip()
object_title = str(current_focus.get("title") or "").strip()
object_id = current_focus.get("object_id")
try:
object_id = int(object_id) if object_id is not None else None
except Exception:
object_id = None
if not object_ref and not object_title:
return {}
topic = str(question or "").strip()[:120] or "外部研究"
summary = str(answer or "").strip()[:240]
return ResearchWorkspace(
workspace_id=f"ws_{hashlib.sha1(topic.encode('utf-8'), usedforsecurity=False).hexdigest()[:12]}",
workspace_kind="hybrid_research" if object_ref else "external_research",
topic=topic,
primary_object_ref=object_ref or None,
primary_object_id=object_id,
primary_object_title=object_title or None,
research_goal=summary or None,
analysis_dimensions=[],
evidence_refs=[],
confidence_state="low",
open_questions=[],
next_recommended_actions=[],
status="active",
).model_dump()
def _merge_context_capsules(
self,
*,
existing: Any,
workspace: dict[str, Any],
) -> list[dict[str, Any]]:
normalized_workspace = self._normalize_active_workspace(workspace)
if not normalized_workspace:
return self._normalize_context_capsules(existing)
capsule = ContextCapsule(
workspace_id=str(normalized_workspace.get("workspace_id") or "").strip(),
workspace_kind=str(normalized_workspace.get("workspace_kind") or "").strip(),
topic=str(normalized_workspace.get("topic") or "").strip(),
primary_object_ref=str(normalized_workspace.get("primary_object_ref") or "").strip() or None,
candidate_set_binding=str(normalized_workspace.get("candidate_set_binding") or "").strip() or None,
summary=str(normalized_workspace.get("research_goal") or normalized_workspace.get("topic") or "").strip(),
open_questions=[
str(item).strip()
for item in list(normalized_workspace.get("open_questions") or [])[:4]
if str(item).strip()
],
next_action=(
str((normalized_workspace.get("next_recommended_actions") or [None])[0] or "").strip()
or None
),
).model_dump()
capsules = self._normalize_context_capsules(existing)
merged = [capsule]
for item in capsules:
if not isinstance(item, dict):
continue
if str(item.get("workspace_id") or "").strip() == str(capsule.get("workspace_id") or "").strip():
continue
merged.append(item)
return merged[:8]
def _collect_entity_ids_from_sources(self, sources: list[dict[str, Any]]) -> list[int]:
explicit_ids: list[int] = []
graph_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"):
value = meta.get(key)
try:
if value is not None:
explicit_ids.append(int(value))
except Exception:
continue
matched = meta.get("matched_project_ids")
if isinstance(matched, list):
for value in matched:
try:
explicit_ids.append(int(value))
except Exception:
continue
for key in ("entity_id", "subject_entity_id", "object_entity_id"):
value = meta.get(key)
try:
if value is not None:
graph_ids.append(int(value))
except Exception:
continue
candidates = explicit_ids or graph_ids
uniq: list[int] = []
for value in candidates:
if value not in uniq:
uniq.append(value)
return uniq[:20]
@staticmethod
def _extract_project_subject_from_answer(answer: str) -> str:
text = str(answer or "").strip()
if not text:
return ""
patterns = (
r"项目名(?:称)?[:: ]*[“\"]?([^”\",。;、\n]{2,80})[”\"]?",
r"项目名[“\"]([^”\"\n]{2,80})[”\"]",
r"项目\s*#\d+[《“\"]([^》”\"\n]{2,80})[》”\"]",
r"#\d+\s+([^\n((]{2,80})[((]",
r"项目[《“\"]([^》”\"\n]{2,80})[》”\"]",
)
for pattern in patterns:
match = re.search(pattern, text)
if match:
return str(match.group(1)).strip()[:255]
return ""
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]
source_type = str(meta.get("source_type") or "").strip().lower()
object_ref = str(meta.get("object_ref") or "").strip().lower()
if source_type == "customer_visible_object" or object_ref.startswith("project:"):
subject_name = str(meta.get("subject_name") or "").strip()
if subject_name:
return subject_name[:255]
for source in sources:
if not isinstance(source, dict):
continue
meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {}
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]
answer_subject = self._extract_project_subject_from_answer(answer)
if answer_subject:
return answer_subject[:255]
text = str(question or "").strip()
normalizer = getattr(self, "_normalize_lookup_text", None)
normalized_question = normalizer(text) if callable(normalizer) else text
if 2 <= len(normalized_question) <= 64:
return normalized_question[: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:],
"active_workspace": self._normalize_active_workspace(getattr(state, "active_workspace", None)),
"context_capsules": self._normalize_context_capsules(getattr(state, "context_capsules", None)),
"pending_clarification": state.pending_clarification if state else None,
"pending_clarification_expires_at": (
state.pending_clarification_expires_at.isoformat()
if state and state.pending_clarification_expires_at
else None
),
"updated_at": state.updated_at.isoformat() if state and state.updated_at else None,
}