Files
PlantAI-dev/gfa_core/mas/persistent_conversation.py
T

536 lines
20 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.
"""Durable conversation history and goal-ledger updates for the main Agent."""
from __future__ import annotations
import re
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
from gfa_core.mas.agent_state_repository import SQLiteAgentStateRepository
from gfa_core.mas.workflow_models import GoalLedger
_INTERACTION_KINDS = {
"supplement",
"correction",
"switch",
"cancel",
"status",
"independent",
}
def is_short_acknowledgement(text: str) -> bool:
normalized = re.sub(r"[\s,,。.!!??;;::]+", "", str(text or "").lower())
return normalized in {
"同意",
"确认",
"可以",
"好的",
"好",
"是",
"是的",
"继续",
"开始吧",
"执行吧",
}
@dataclass(frozen=True)
class ConversationTurn:
conversation_id: str
job_id: str
query: str
history: tuple[dict[str, str], ...]
goal_ledger: GoalLedger
def _conversation_id(session_id: str) -> str:
value = str(session_id or "").strip()
if not value:
return str(uuid4())
try:
parsed = UUID(value)
except ValueError:
return str(uuid5(NAMESPACE_URL, f"plantai-session:{value}"))
return str(parsed) if str(parsed) == value else str(
uuid5(NAMESPACE_URL, f"plantai-session:{value}")
)
def _history(items: Any) -> tuple[dict[str, str], ...]:
normalized: list[dict[str, str]] = []
for item in items or ():
if not isinstance(item, dict) or item.get("role") not in {"user", "assistant"}:
continue
content = str(item.get("content") or "")
if content:
normalized.append({"role": item["role"], "content": content})
return tuple(normalized[-10:])
class PersistentConversationStore:
def __init__(self, repository: SQLiteAgentStateRepository) -> None:
self.repository = repository
def resolve_job_binding(self, *, session_id: str, requested_job_id: str) -> str:
"""Return the one job owned by a conversation, rejecting mixed jobs."""
conversation_id = _conversation_id(session_id)
conversation = self.repository.get_conversation(conversation_id)
requested = str(requested_job_id or "").strip()
if conversation is None:
return requested
ledger = self.repository.get_goal_ledger(conversation_id)
conversation_job = str(conversation.job_id or "").strip()
ledger_job = str(
(ledger.user_terms if ledger is not None else {}).get("active_job_id")
or ""
).strip()
persisted_jobs = {value for value in (conversation_job, ledger_job) if value}
if len(persisted_jobs) > 1:
raise ValueError("conversation job binding conflict")
bound = next(iter(persisted_jobs), "")
if requested and bound and requested != bound:
raise ValueError("conversation job binding conflict")
return bound or requested
def rebind_job(self, *, session_id: str, job_id: str) -> str:
"""Bind one conversation to one job without mixing histories."""
conversation_id = _conversation_id(session_id)
requested = str(job_id or "").strip()
if not requested:
raise ValueError("job_id is required")
conversation = self.repository.get_conversation(conversation_id)
if conversation is None:
raise ValueError("conversation is missing")
previous_job = str(conversation.job_id or "")
current = self.repository.get_goal_ledger(conversation_id)
ledger_job = str(
(current.user_terms if current is not None else {}).get("active_job_id")
or ""
).strip()
bound_job = previous_job or ledger_job
if bound_job == requested:
return conversation_id
if bound_job:
replacement_id = str(uuid4())
self.repository.create_conversation(
conversation_id=replacement_id,
principal_id=conversation.principal_id,
job_id=requested,
)
self.repository.put_goal_ledger(
GoalLedger(
conversation_id=replacement_id,
revision=0,
user_terms={"active_job_id": requested},
),
expected_revision=None,
)
return replacement_id
self.repository.update_conversation(
conversation_id=conversation_id,
principal_id=conversation.principal_id,
job_id=requested,
)
if current is None:
return conversation_id
replacement = GoalLedger(
conversation_id=conversation_id,
revision=current.revision + 1,
user_terms={"active_job_id": requested},
)
try:
self.repository.put_goal_ledger(
replacement,
expected_revision=current.revision,
)
except Exception:
self.repository.update_conversation(
conversation_id=conversation_id,
principal_id=conversation.principal_id,
job_id=previous_job,
)
raise
return conversation_id
def begin_turn(
self,
*,
session_id: str,
job_id: str,
query: str,
client_history: Any,
) -> ConversationTurn:
conversation_id = _conversation_id(session_id)
conversation = self.repository.get_conversation(conversation_id)
if conversation is None:
conversation = self.repository.create_conversation(
conversation_id=conversation_id,
principal_id="web-session",
job_id=str(job_id or ""),
)
ledger = self.repository.get_goal_ledger(conversation_id)
if ledger is None:
initial_terms = {"active_job_id": job_id} if job_id else {}
ledger = self.repository.put_goal_ledger(
GoalLedger(
conversation_id=conversation_id,
revision=0,
user_terms=initial_terms,
),
expected_revision=None,
)
effective_job_id = self.resolve_job_binding(
session_id=conversation_id,
requested_job_id=job_id,
)
if effective_job_id and not conversation.job_id:
conversation = self.repository.update_conversation(
conversation_id=conversation_id,
principal_id=conversation.principal_id,
job_id=effective_job_id,
)
if effective_job_id and not ledger.user_terms.get("active_job_id"):
serialized = ledger.model_dump(mode="json")
user_terms = dict(serialized["user_terms"])
user_terms["active_job_id"] = effective_job_id
ledger = self.repository.put_goal_ledger(
GoalLedger(
**{
**serialized,
"revision": ledger.revision + 1,
"user_terms": user_terms,
}
),
expected_revision=ledger.revision,
)
stored_messages = self.repository.list_recent_messages(conversation_id, limit=10)
if not stored_messages:
for item in _history(client_history):
persisted = self.repository.append_message(
conversation_id=conversation_id,
message_id=str(uuid4()),
role=item["role"],
content=item["content"],
expected_revision=conversation.revision,
)
conversation = self.repository.get_conversation(conversation_id)
assert conversation is not None
assert conversation.revision == persisted.revision
stored_messages = self.repository.list_recent_messages(
conversation_id,
limit=10,
)
current_user_already_saved = bool(
stored_messages
and stored_messages[-1].role == "user"
and stored_messages[-1].content == query
)
history_messages = (
stored_messages[:-1]
if current_user_already_saved
else stored_messages
)
prior_history = tuple(
{"role": item.role, "content": item.content}
for item in history_messages
if item.role in {"user", "assistant"}
)
if not current_user_already_saved:
self.repository.append_message(
conversation_id=conversation_id,
message_id=str(uuid4()),
role="user",
content=query,
expected_revision=conversation.revision,
)
return ConversationTurn(
conversation_id=conversation_id,
job_id=effective_job_id,
query=query,
history=prior_history,
goal_ledger=ledger,
)
def finish_turn(
self,
turn: ConversationTurn,
*,
assistant_message: str,
interaction_kind: str,
action: str | None,
intent: dict[str, Any] | None = None,
proposal: dict[str, str] | None = None,
) -> GoalLedger:
if interaction_kind not in _INTERACTION_KINDS:
raise ValueError("interaction_kind is invalid")
conversation = self.repository.get_conversation(turn.conversation_id)
if conversation is None:
raise ValueError("conversation is missing")
self.repository.append_message(
conversation_id=turn.conversation_id,
message_id=str(uuid4()),
role="assistant",
content=assistant_message,
expected_revision=conversation.revision,
)
current = self.repository.get_goal_ledger(turn.conversation_id)
if current is None:
raise ValueError("goal ledger is missing")
active_goal = current.active_goal
queued_goals = list(current.queued_goals)
corrections = list(current.corrections)
prior_terms = dict(current.user_terms)
pending_proposal = prior_terms.get("pending_proposal")
pending_proposal = (
dict(pending_proposal)
if isinstance(pending_proposal, Mapping)
else None
)
confirmed_pending_proposal = bool(
action == "propose_analysis"
and pending_proposal is not None
and str(pending_proposal.get("job_id") or turn.job_id)
== turn.job_id
and isinstance(proposal, Mapping)
and str(proposal.get("proposal_id") or "")
== str(pending_proposal.get("proposal_id") or "")
)
prior_stages = {
str(stage)
for stage in prior_terms.get("requested_stages", ())
if isinstance(stage, str) and stage.strip()
}
current_stages = {
str(stage)
for stage in ((intent or {}).get("stages") or ())
if isinstance(stage, str) and stage.strip()
}
prior_family = str(prior_terms.get("gene_family") or "").strip()
current_family = str((intent or {}).get("gene_family") or "").strip()
is_current_phase = bool(
active_goal
and action == "propose_analysis"
and current_stages
and prior_stages
and current_stages < prior_stages
and not (
prior_family
and current_family
and prior_family != current_family
)
)
if interaction_kind == "cancel":
active_goal = None
elif interaction_kind == "correction":
active_goal = turn.query
if turn.query not in corrections:
corrections.append(turn.query)
elif interaction_kind == "switch":
active_goal = turn.query
elif interaction_kind == "independent" and action in {
"propose_analysis",
"propose_utility",
} and not is_current_phase and not (
active_goal and is_short_acknowledgement(turn.query)
):
if active_goal and active_goal != turn.query and active_goal not in queued_goals:
queued_goals.append(active_goal)
active_goal = turn.query
elif (
interaction_kind == "supplement"
and active_goal is None
and action in {"propose_analysis", "propose_utility"}
):
active_goal = (
str(pending_proposal.get("request") or turn.query)
if pending_proposal
and (
is_short_acknowledgement(turn.query)
or confirmed_pending_proposal
)
else turn.query
)
if active_goal in queued_goals:
queued_goals.remove(active_goal)
serialized = current.model_dump(mode="json")
user_terms = dict(serialized["user_terms"])
if turn.job_id:
user_terms["active_job_id"] = turn.job_id
if action == "clarify" and isinstance(proposal, dict):
normalized_proposal = {
key: str(proposal.get(key) or "").strip()
for key in ("proposal_id", "request", "subject")
}
if all(normalized_proposal.values()):
original_request = str(
proposal.get("original_request") or ""
).strip()
if original_request:
normalized_proposal["original_request"] = original_request
workflow_id = str(proposal.get("workflow_id") or "").strip()
if workflow_id in {
"data_preparation",
"gene_family",
"rnaseq_whole_genome",
"rnaseq_family_member",
}:
normalized_proposal["workflow_id"] = workflow_id
normalized_proposal["job_id"] = turn.job_id
user_terms["pending_proposal"] = normalized_proposal
if (
action == "propose_analysis"
and (
is_short_acknowledgement(turn.query)
or confirmed_pending_proposal
)
and pending_proposal is not None
and str(pending_proposal.get("job_id") or turn.job_id)
== turn.job_id
) or interaction_kind in {"cancel", "correction", "switch"}:
user_terms.pop("pending_proposal", None)
if (
action == "clarify"
or is_current_phase
or (
action == "propose_analysis"
and interaction_kind == "supplement"
and active_goal is not None
)
):
user_terms["current_action"] = turn.query
if isinstance(intent, dict):
scalar_fields = {
"gene_family": ("gene_family",),
"species": ("species",),
"scope_mode": ("scope_mode", "scopeMode"),
"primary_stage": ("primary_stage", "stage"),
"task_kind": ("task_kind", "taskType"),
"prefix": ("prefix",),
"rna_route": ("rna_route", "rnaRoute", "analysis_route"),
"foreground_mode": ("foreground_mode", "foregroundMode"),
}
for target, sources in scalar_fields.items():
value = next(
(
intent.get(source)
for source in sources
if isinstance(intent.get(source), str)
and intent[source].strip()
),
None,
)
if value is not None:
user_terms[target] = value
sequence_fields = {
"target_species": ("target_species", "targetSpecies"),
"reference_species": ("reference_species", "referenceSpecies"),
"comparative_species": (
"comparative_species",
"comparativeSpecies",
),
"requested_stages": ("stages",),
}
params = intent.get("params")
params = params if isinstance(params, dict) else {}
for target, sources in sequence_fields.items():
raw = next(
(
intent.get(source, params.get(source))
for source in sources
if isinstance(intent.get(source, params.get(source)), (list, tuple))
),
None,
)
if (
raw is not None
and (
target == "requested_stages"
or bool(raw)
or target not in user_terms
)
and all(
isinstance(item, str) and item.strip() for item in raw
)
):
user_terms[target] = list(raw)
changed = (
active_goal != current.active_goal
or tuple(queued_goals) != current.queued_goals
or tuple(corrections) != current.corrections
or user_terms != dict(current.user_terms)
)
if not changed:
return current
updated = GoalLedger(
**{
**serialized,
"revision": current.revision + 1,
"active_goal": active_goal,
"queued_goals": tuple(queued_goals),
"corrections": tuple(corrections),
"user_terms": user_terms,
}
)
return self.repository.put_goal_ledger(
updated,
expected_revision=current.revision,
)
def record_config_confirmation(
self,
*,
session_id: str,
job_id: str,
evidence_id: str,
) -> GoalLedger | None:
"""Attach a confirmed config to an existing conversation goal."""
conversation_id = _conversation_id(session_id)
if self.repository.get_conversation(conversation_id) is None:
return None
bound_job_id = self.resolve_job_binding(
session_id=conversation_id,
requested_job_id=job_id,
)
current = self.repository.get_goal_ledger(conversation_id)
if current is None:
return None
approved_decisions = list(current.approved_decisions)
if "config.xlsx 已确认" not in approved_decisions:
approved_decisions.append("config.xlsx 已确认")
evidence_ids = list(current.evidence_ids)
if evidence_id not in evidence_ids:
evidence_ids.append(evidence_id)
serialized = current.model_dump(mode="json")
user_terms = dict(serialized["user_terms"])
user_terms["config_confirmed"] = True
if bound_job_id:
user_terms["active_job_id"] = bound_job_id
unresolved_blockers = tuple(
blocker
for blocker in current.unresolved_blockers
if "config" not in blocker.lower() and "配置" not in blocker
)
updated = GoalLedger(
**{
**serialized,
"revision": current.revision + 1,
"approved_decisions": tuple(approved_decisions),
"unresolved_blockers": unresolved_blockers,
"user_terms": user_terms,
"evidence_ids": tuple(evidence_ids),
}
)
return self.repository.put_goal_ledger(
updated,
expected_revision=current.revision,
)