Files

558 lines
21 KiB
Python
Raw Permalink Normal View History

2026-08-03 18:42:47 +08:00
"""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 CurrentRecommendation, GoalLedger
2026-08-03 18:42:47 +08:00
_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)
2026-08-03 18:42:47 +08:00
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."""
2026-08-03 18:42:47 +08:00
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
2026-08-03 18:42:47 +08:00
self.repository.update_conversation(
conversation_id=conversation_id,
principal_id=conversation.principal_id,
job_id=requested,
)
if current is None:
return conversation_id
2026-08-03 18:42:47 +08:00
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
2026-08-03 18:42:47 +08:00
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_messages(conversation_id)
2026-08-03 18:42:47 +08:00
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_messages(conversation_id)
2026-08-03 18:42:47 +08:00
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,
recommendation: Mapping[str, Any] | None = None,
2026-08-03 18:42:47 +08:00
) -> 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 == "show_result":
if recommendation is None:
user_terms.pop("current_recommendation", None)
else:
current_recommendation = CurrentRecommendation.model_validate(
recommendation
)
if current_recommendation.job_id != turn.job_id:
raise ValueError("recommendation job binding conflict")
user_terms["current_recommendation"] = (
current_recommendation.model_dump(mode="json", exclude_none=True)
)
if interaction_kind in {"cancel", "correction", "switch"}:
user_terms.pop("current_recommendation", None)
2026-08-03 18:42:47 +08:00
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
2026-08-03 18:42:47 +08:00
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
action_binding = {
key: str(proposal.get(key) or "").strip()
for key in (
"workflow_action_id",
"authorized_stages",
"target_stage",
)
}
if all(action_binding.values()):
normalized_proposal.update(action_binding)
2026-08-03 18:42:47 +08:00
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,
)