655 lines
28 KiB
Python
655 lines
28 KiB
Python
"""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,
|
||
}
|