1746 lines
63 KiB
Python
1746 lines
63 KiB
Python
"""
|
||
Graph retrieval tools service.
|
||
|
||
Wraps graph search, node reading, edge query and other tools for the
|
||
Report Agent to use.
|
||
|
||
Core retrieval tools (optimized):
|
||
1. InsightForge (deep insight retrieval) - the most powerful hybrid retrieval,
|
||
automatically generating sub-questions and retrieving across multiple dimensions.
|
||
2. PanoramaSearch (breadth search) - get the full picture, including expired content.
|
||
3. QuickSearch (simple search) - fast retrieval.
|
||
"""
|
||
|
||
import time
|
||
import json
|
||
from typing import Dict, Any, List, Optional
|
||
from dataclasses import dataclass, field
|
||
|
||
from ..config import Config
|
||
from ..utils.logger import get_logger
|
||
from ..utils.llm_client import LLMClient
|
||
from ..utils.locale import get_locale, t
|
||
from ..utils.embeddings import embed, cosine_similarity
|
||
from .local_graph_store import LocalGraphStore, LocalNode, LocalEdge
|
||
|
||
logger = get_logger("mirofish.graph_tools")
|
||
|
||
|
||
@dataclass
|
||
class SearchResult:
|
||
"""Search result."""
|
||
|
||
facts: List[str]
|
||
edges: List[Dict[str, Any]]
|
||
nodes: List[Dict[str, Any]]
|
||
query: str
|
||
total_count: int
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"facts": self.facts,
|
||
"edges": self.edges,
|
||
"nodes": self.nodes,
|
||
"query": self.query,
|
||
"total_count": self.total_count,
|
||
}
|
||
|
||
def to_text(self) -> str:
|
||
"""Convert to text format for the LLM to understand."""
|
||
text_parts = [
|
||
f"Search query: {self.query}",
|
||
f"Found {self.total_count} related pieces of information",
|
||
]
|
||
|
||
if self.facts:
|
||
text_parts.append("\n### Related Facts:")
|
||
for i, fact in enumerate(self.facts, 1):
|
||
text_parts.append(f"{i}. {fact}")
|
||
|
||
return "\n".join(text_parts)
|
||
|
||
|
||
@dataclass
|
||
class NodeInfo:
|
||
"""Node information."""
|
||
|
||
uuid: str
|
||
name: str
|
||
labels: List[str]
|
||
summary: str
|
||
attributes: Dict[str, Any]
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"uuid": self.uuid,
|
||
"name": self.name,
|
||
"labels": self.labels,
|
||
"summary": self.summary,
|
||
"attributes": self.attributes,
|
||
}
|
||
|
||
def to_text(self) -> str:
|
||
"""Convert to text format."""
|
||
entity_type = next(
|
||
(l for l in self.labels if l not in ["Entity", "Node"]),
|
||
t("api.unknownType"),
|
||
)
|
||
return f"Entity: {self.name} (type: {entity_type})\nSummary: {self.summary}"
|
||
|
||
|
||
@dataclass
|
||
class EdgeInfo:
|
||
"""Edge information."""
|
||
|
||
uuid: str
|
||
name: str
|
||
fact: str
|
||
source_node_uuid: str
|
||
target_node_uuid: str
|
||
source_node_name: Optional[str] = None
|
||
target_node_name: Optional[str] = None
|
||
# Time info
|
||
created_at: Optional[str] = None
|
||
valid_at: Optional[str] = None
|
||
invalid_at: Optional[str] = None
|
||
expired_at: Optional[str] = None
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"uuid": self.uuid,
|
||
"name": self.name,
|
||
"fact": self.fact,
|
||
"source_node_uuid": self.source_node_uuid,
|
||
"target_node_uuid": self.target_node_uuid,
|
||
"source_node_name": self.source_node_name,
|
||
"target_node_name": self.target_node_name,
|
||
"created_at": self.created_at,
|
||
"valid_at": self.valid_at,
|
||
"invalid_at": self.invalid_at,
|
||
"expired_at": self.expired_at,
|
||
}
|
||
|
||
def to_text(self, include_temporal: bool = False) -> str:
|
||
"""Convert to text format."""
|
||
source = self.source_node_name or self.source_node_uuid[:8]
|
||
target = self.target_node_name or self.target_node_uuid[:8]
|
||
base_text = (
|
||
f"Relationship: {source} --[{self.name}]--> {target}\nFact: {self.fact}"
|
||
)
|
||
|
||
if include_temporal:
|
||
valid_at = self.valid_at or t("api.unknown")
|
||
invalid_at = self.invalid_at or "to date"
|
||
base_text += f"\nValidity: {valid_at} - {invalid_at}"
|
||
if self.expired_at:
|
||
base_text += f" (expired: {self.expired_at})"
|
||
|
||
return base_text
|
||
|
||
@property
|
||
def is_expired(self) -> bool:
|
||
"""Whether the edge has expired."""
|
||
return self.expired_at is not None
|
||
|
||
@property
|
||
def is_invalid(self) -> bool:
|
||
"""Whether the edge is invalid."""
|
||
return self.invalid_at is not None
|
||
|
||
|
||
@dataclass
|
||
class InsightForgeResult:
|
||
"""
|
||
Deep insight retrieval result (InsightForge).
|
||
|
||
Contains retrieval results for multiple sub-questions as well as the
|
||
integrated analysis.
|
||
"""
|
||
|
||
query: str
|
||
simulation_requirement: str
|
||
sub_queries: List[str]
|
||
|
||
# Retrieval results by dimension
|
||
semantic_facts: List[str] = field(default_factory=list) # Semantic search results
|
||
entity_insights: List[Dict[str, Any]] = field(default_factory=list) # Entity insights
|
||
relationship_chains: List[str] = field(default_factory=list) # Relationship chains
|
||
|
||
# Statistics
|
||
total_facts: int = 0
|
||
total_entities: int = 0
|
||
total_relationships: int = 0
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"query": self.query,
|
||
"simulation_requirement": self.simulation_requirement,
|
||
"sub_queries": self.sub_queries,
|
||
"semantic_facts": self.semantic_facts,
|
||
"entity_insights": self.entity_insights,
|
||
"relationship_chains": self.relationship_chains,
|
||
"total_facts": self.total_facts,
|
||
"total_entities": self.total_entities,
|
||
"total_relationships": self.total_relationships,
|
||
}
|
||
|
||
def to_text(self) -> str:
|
||
"""Convert to detailed text format for the LLM to understand."""
|
||
text_parts = [
|
||
f"## Future Prediction Deep Analysis",
|
||
f"Analysis question: {self.query}",
|
||
f"Prediction scenario: {self.simulation_requirement}",
|
||
f"\n### Prediction Data Statistics",
|
||
f"- Related prediction facts: {self.total_facts}",
|
||
f"- Involved entities: {self.total_entities}",
|
||
f"- Relationship chains: {self.total_relationships}",
|
||
]
|
||
|
||
# Sub-queries
|
||
if self.sub_queries:
|
||
text_parts.append(f"\n### Sub-questions Analyzed")
|
||
for i, sq in enumerate(self.sub_queries, 1):
|
||
text_parts.append(f"{i}. {sq}")
|
||
|
||
# Semantic search results
|
||
if self.semantic_facts:
|
||
text_parts.append(
|
||
f"\n### [Key Facts] (please quote these original texts in the report)"
|
||
)
|
||
for i, fact in enumerate(self.semantic_facts, 1):
|
||
text_parts.append(f'{i}. "{fact}"')
|
||
|
||
# Entity insights
|
||
if self.entity_insights:
|
||
text_parts.append(f"\n### [Core Entities]")
|
||
for entity in self.entity_insights:
|
||
text_parts.append(
|
||
f"- **{entity.get('name', 'Unknown')}** ({entity.get('type', 'Entity')})"
|
||
)
|
||
if entity.get("summary"):
|
||
text_parts.append(f' Summary: "{entity.get("summary")}"')
|
||
if entity.get("related_facts"):
|
||
text_parts.append(
|
||
f" Related facts: {len(entity.get('related_facts', []))}"
|
||
)
|
||
|
||
# Relationship chains
|
||
if self.relationship_chains:
|
||
text_parts.append(f"\n### [Relationship Chains]")
|
||
for chain in self.relationship_chains:
|
||
text_parts.append(f"- {chain}")
|
||
|
||
return "\n".join(text_parts)
|
||
|
||
|
||
@dataclass
|
||
class PanoramaResult:
|
||
"""
|
||
Breadth search result (Panorama).
|
||
|
||
Contains all related information, including expired content.
|
||
"""
|
||
|
||
query: str
|
||
|
||
# All nodes
|
||
all_nodes: List[NodeInfo] = field(default_factory=list)
|
||
# All edges (including expired)
|
||
all_edges: List[EdgeInfo] = field(default_factory=list)
|
||
# Currently active facts
|
||
active_facts: List[str] = field(default_factory=list)
|
||
# Expired/invalid facts (historical)
|
||
historical_facts: List[str] = field(default_factory=list)
|
||
|
||
# Statistics
|
||
total_nodes: int = 0
|
||
total_edges: int = 0
|
||
active_count: int = 0
|
||
historical_count: int = 0
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"query": self.query,
|
||
"all_nodes": [n.to_dict() for n in self.all_nodes],
|
||
"all_edges": [e.to_dict() for e in self.all_edges],
|
||
"active_facts": self.active_facts,
|
||
"historical_facts": self.historical_facts,
|
||
"total_nodes": self.total_nodes,
|
||
"total_edges": self.total_edges,
|
||
"active_count": self.active_count,
|
||
"historical_count": self.historical_count,
|
||
}
|
||
|
||
def to_text(self) -> str:
|
||
"""Convert to text format (full version, not truncated)."""
|
||
text_parts = [
|
||
f"## Broad Search Results (Future Panorama View)",
|
||
f"Query: {self.query}",
|
||
f"\n### Statistics",
|
||
f"- Total nodes: {self.total_nodes}",
|
||
f"- Total edges: {self.total_edges}",
|
||
f"- Currently valid facts: {self.active_count}",
|
||
f"- Historical/expired facts: {self.historical_count}",
|
||
]
|
||
|
||
# Currently active facts (full output, no truncation)
|
||
if self.active_facts:
|
||
text_parts.append(
|
||
f"\n### [Currently Valid Facts] (original simulation results)"
|
||
)
|
||
for i, fact in enumerate(self.active_facts, 1):
|
||
text_parts.append(f'{i}. "{fact}"')
|
||
|
||
# Historical/expired facts (full output, no truncation)
|
||
if self.historical_facts:
|
||
text_parts.append(
|
||
f"\n### [Historical/Expired Facts] (evolution process records)"
|
||
)
|
||
for i, fact in enumerate(self.historical_facts, 1):
|
||
text_parts.append(f'{i}. "{fact}"')
|
||
|
||
# Key entities (full output, no truncation)
|
||
if self.all_nodes:
|
||
text_parts.append(f"\n### [Involved Entities]")
|
||
for node in self.all_nodes:
|
||
entity_type = next(
|
||
(l for l in node.labels if l not in ["Entity", "Node"]), "Entity"
|
||
)
|
||
text_parts.append(f"- **{node.name}** ({entity_type})")
|
||
|
||
return "\n".join(text_parts)
|
||
|
||
|
||
@dataclass
|
||
class AgentInterview:
|
||
"""Interview result for a single Agent."""
|
||
|
||
agent_name: str
|
||
agent_role: str # Role type (e.g. student, teacher, media, etc.)
|
||
agent_bio: str # Bio
|
||
question: str # Interview question
|
||
response: str # Interview response
|
||
key_quotes: List[str] = field(default_factory=list) # Key quotes
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"agent_name": self.agent_name,
|
||
"agent_role": self.agent_role,
|
||
"agent_bio": self.agent_bio,
|
||
"question": self.question,
|
||
"response": self.response,
|
||
"key_quotes": self.key_quotes,
|
||
}
|
||
|
||
def to_text(self) -> str:
|
||
text = f"**{self.agent_name}** ({self.agent_role})\n"
|
||
# Show full agent_bio, no truncation
|
||
text += f"_Bio: {self.agent_bio}_\n\n"
|
||
text += f"**Q:** {self.question}\n\n"
|
||
text += f"**A:** {self.response}\n"
|
||
if self.key_quotes:
|
||
text += "\n**Key Quotes:**\n"
|
||
for quote in self.key_quotes:
|
||
# Clean various quote marks
|
||
clean_quote = (
|
||
quote.replace("\u201c", "").replace("\u201d", "").replace('"', "")
|
||
)
|
||
clean_quote = clean_quote.replace("\u300c", "").replace("\u300d", "")
|
||
clean_quote = clean_quote.strip()
|
||
# Strip leading punctuation
|
||
while clean_quote and clean_quote[0] in ",,;;::、。!?\n\r\t ":
|
||
clean_quote = clean_quote[1:]
|
||
# Filter garbage content containing question numbers (Q1-9)
|
||
skip = False
|
||
for d in "123456789":
|
||
if f"\u95ee\u9898{d}" in clean_quote:
|
||
skip = True
|
||
break
|
||
if skip:
|
||
continue
|
||
# Truncate long content (by sentence, not hard cut)
|
||
if len(clean_quote) > 150:
|
||
dot_pos = clean_quote.find("\u3002", 80)
|
||
if dot_pos > 0:
|
||
clean_quote = clean_quote[: dot_pos + 1]
|
||
else:
|
||
clean_quote = clean_quote[:147] + "..."
|
||
if clean_quote and len(clean_quote) >= 10:
|
||
text += f'> "{clean_quote}"\n'
|
||
return text
|
||
|
||
|
||
@dataclass
|
||
class InterviewResult:
|
||
"""
|
||
Interview result.
|
||
|
||
Contains interview answers from multiple simulated Agents.
|
||
"""
|
||
|
||
interview_topic: str # Interview topic
|
||
interview_questions: List[str] # Interview question list
|
||
|
||
# Agents selected for interview
|
||
selected_agents: List[Dict[str, Any]] = field(default_factory=list)
|
||
# Interview responses per Agent
|
||
interviews: List[AgentInterview] = field(default_factory=list)
|
||
|
||
# Reason for selecting Agents
|
||
selection_reasoning: str = ""
|
||
# Consolidated interview summary
|
||
summary: str = ""
|
||
|
||
# Statistics
|
||
total_agents: int = 0
|
||
interviewed_count: int = 0
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"interview_topic": self.interview_topic,
|
||
"interview_questions": self.interview_questions,
|
||
"selected_agents": self.selected_agents,
|
||
"interviews": [i.to_dict() for i in self.interviews],
|
||
"selection_reasoning": self.selection_reasoning,
|
||
"summary": self.summary,
|
||
"total_agents": self.total_agents,
|
||
"interviewed_count": self.interviewed_count,
|
||
}
|
||
|
||
def to_text(self) -> str:
|
||
"""Convert to detailed text format for the LLM to understand and quote in the report."""
|
||
text_parts = [
|
||
"## Deep Interview Report",
|
||
f"**Interview topic:** {self.interview_topic}",
|
||
f"**Interview count:** {self.interviewed_count} / {self.total_agents} simulation agents",
|
||
"\n### Interview Subject Selection Rationale",
|
||
self.selection_reasoning or "(auto-selected)",
|
||
"\n---",
|
||
"\n### Interview Transcript",
|
||
]
|
||
|
||
if self.interviews:
|
||
for i, interview in enumerate(self.interviews, 1):
|
||
text_parts.append(f"\n#### Interview #{i}: {interview.agent_name}")
|
||
text_parts.append(interview.to_text())
|
||
text_parts.append("\n---")
|
||
else:
|
||
text_parts.append("(No interview records)\n\n---")
|
||
|
||
text_parts.append("\n### Interview Summary and Core Viewpoints")
|
||
text_parts.append(self.summary or "(No summary)")
|
||
|
||
return "\n".join(text_parts)
|
||
|
||
|
||
class GraphToolsService:
|
||
"""
|
||
Graph retrieval tools service.
|
||
|
||
[Core retrieval tools - optimized]
|
||
1. insight_forge - deep insight retrieval (most powerful; auto-generates
|
||
sub-questions and retrieves across multiple dimensions).
|
||
2. panorama_search - breadth search (gets the full picture, including
|
||
expired content).
|
||
3. quick_search - simple search (fast retrieval).
|
||
4. interview_agents - deep interview (interviews simulated Agents to obtain
|
||
multi-perspective views).
|
||
|
||
[Basic tools]
|
||
- search_graph - semantic graph search.
|
||
- get_all_nodes - get all nodes in the graph.
|
||
- get_all_edges - get all edges in the graph (including temporal info).
|
||
- get_node_detail - get detailed information of a node.
|
||
- get_node_edges - get edges related to a node.
|
||
- get_entities_by_type - get entities by type.
|
||
- get_entity_summary - get a relationship summary for an entity.
|
||
"""
|
||
|
||
def __init__(
|
||
self, api_key: Optional[str] = None, llm_client: Optional[LLMClient] = None
|
||
):
|
||
# LLM client for InsightForge sub-query generation
|
||
self._llm_client = llm_client
|
||
logger.info(t("console.graphToolsInitialized"))
|
||
|
||
@property
|
||
def llm(self) -> LLMClient:
|
||
"""Lazily initialize the LLM client."""
|
||
if self._llm_client is None:
|
||
self._llm_client = LLMClient()
|
||
return self._llm_client
|
||
|
||
def search_graph(
|
||
self, graph_id: str, query: str, limit: int = 10, scope: str = "edges"
|
||
) -> SearchResult:
|
||
"""
|
||
Semantic graph search.
|
||
|
||
Uses the local graph store for searching:
|
||
- If a node/edge has an embedding, re-rank embed(query) with cosine_similarity.
|
||
- Otherwise use SQL LIKE keyword matching (already implemented by the store).
|
||
|
||
Args:
|
||
graph_id: Graph ID (Standalone Graph).
|
||
query: Search query.
|
||
limit: Number of results to return.
|
||
scope: Search scope, "edges" or "nodes".
|
||
|
||
Returns:
|
||
SearchResult: The search result.
|
||
"""
|
||
logger.info(t("console.graphSearch", graphId=graph_id, query=query[:50]))
|
||
|
||
store = LocalGraphStore(graph_id)
|
||
facts = []
|
||
edges = []
|
||
nodes = []
|
||
|
||
# Try to get query embedding for semantic reranking
|
||
query_embedding = embed(query)
|
||
|
||
if scope in ("edges", "both"):
|
||
local_edges = store.search_edges(query, limit)
|
||
# If embedding available, rerank with cosine_similarity
|
||
if query_embedding is not None:
|
||
scored = []
|
||
for e in local_edges:
|
||
if e.embedding:
|
||
sim = cosine_similarity(query_embedding, e.embedding)
|
||
else:
|
||
sim = 0.0
|
||
scored.append((sim, e))
|
||
scored.sort(key=lambda x: x[0], reverse=True)
|
||
local_edges = [e for _, e in scored[:limit]]
|
||
|
||
for e in local_edges:
|
||
if e.fact:
|
||
facts.append(e.fact)
|
||
edges.append(
|
||
{
|
||
"uuid": e.uuid,
|
||
"name": e.name,
|
||
"fact": e.fact,
|
||
"source_node_uuid": e.source_node_uuid,
|
||
"target_node_uuid": e.target_node_uuid,
|
||
}
|
||
)
|
||
|
||
if scope in ("nodes", "both"):
|
||
local_nodes = store.search_nodes(query, limit)
|
||
if query_embedding is not None:
|
||
scored = []
|
||
for n in local_nodes:
|
||
if n.embedding:
|
||
sim = cosine_similarity(query_embedding, n.embedding)
|
||
else:
|
||
sim = 0.0
|
||
scored.append((sim, n))
|
||
scored.sort(key=lambda x: x[0], reverse=True)
|
||
local_nodes = [n for _, n in scored[:limit]]
|
||
|
||
for n in local_nodes:
|
||
nodes.append(
|
||
{
|
||
"uuid": n.uuid,
|
||
"name": n.name,
|
||
"labels": n.labels,
|
||
"summary": n.summary,
|
||
}
|
||
)
|
||
if n.summary:
|
||
facts.append(f"[{n.name}]: {n.summary}")
|
||
|
||
logger.info(t("console.searchComplete", count=len(facts)))
|
||
|
||
return SearchResult(
|
||
facts=facts,
|
||
edges=edges,
|
||
nodes=nodes,
|
||
query=query,
|
||
total_count=len(facts),
|
||
)
|
||
|
||
def get_all_nodes(self, graph_id: str) -> List[NodeInfo]:
|
||
"""
|
||
Get all nodes in the graph.
|
||
|
||
Args:
|
||
graph_id: Graph ID.
|
||
|
||
Returns:
|
||
List of nodes.
|
||
"""
|
||
logger.info(t("console.fetchingAllNodes", graphId=graph_id))
|
||
|
||
store = LocalGraphStore(graph_id)
|
||
nodes = store.get_all_nodes()
|
||
|
||
result = []
|
||
for node in nodes:
|
||
result.append(
|
||
NodeInfo(
|
||
uuid=node.uuid or "",
|
||
name=node.name or "",
|
||
labels=node.labels or [],
|
||
summary=node.summary or "",
|
||
attributes=node.attributes or {},
|
||
)
|
||
)
|
||
|
||
logger.info(t("console.fetchedNodes", count=len(result)))
|
||
return result
|
||
|
||
def get_all_edges(
|
||
self, graph_id: str, include_temporal: bool = True
|
||
) -> List[EdgeInfo]:
|
||
"""
|
||
Get all edges in the graph (including temporal information).
|
||
|
||
Args:
|
||
graph_id: Graph ID.
|
||
include_temporal: Whether to include temporal info (default True).
|
||
|
||
Returns:
|
||
List of edges (including created_at, valid_at, invalid_at, expired_at).
|
||
"""
|
||
logger.info(t("console.fetchingAllEdges", graphId=graph_id))
|
||
|
||
store = LocalGraphStore(graph_id)
|
||
edges = store.get_all_edges()
|
||
|
||
result = []
|
||
for edge in edges:
|
||
edge_info = EdgeInfo(
|
||
uuid=edge.uuid or "",
|
||
name=edge.name or "",
|
||
fact=edge.fact or "",
|
||
source_node_uuid=edge.source_node_uuid or "",
|
||
target_node_uuid=edge.target_node_uuid or "",
|
||
)
|
||
|
||
if include_temporal:
|
||
edge_info.created_at = edge.created_at
|
||
edge_info.valid_at = edge.valid_at
|
||
edge_info.invalid_at = edge.invalid_at
|
||
edge_info.expired_at = edge.expired_at
|
||
|
||
result.append(edge_info)
|
||
|
||
logger.info(t("console.fetchedEdges", count=len(result)))
|
||
return result
|
||
|
||
def get_node_detail(self, graph_id: str, node_uuid: str) -> Optional[NodeInfo]:
|
||
"""
|
||
Get detailed information of a single node.
|
||
|
||
Args:
|
||
graph_id: Graph ID.
|
||
node_uuid: Node UUID.
|
||
|
||
Returns:
|
||
Node information, or None.
|
||
"""
|
||
logger.info(t("console.fetchingNodeDetail", uuid=node_uuid[:8]))
|
||
|
||
try:
|
||
store = LocalGraphStore(graph_id)
|
||
node = store.get_node(node_uuid)
|
||
|
||
if not node:
|
||
return None
|
||
|
||
return NodeInfo(
|
||
uuid=node.uuid or "",
|
||
name=node.name or "",
|
||
labels=node.labels or [],
|
||
summary=node.summary or "",
|
||
attributes=node.attributes or {},
|
||
)
|
||
except Exception as e:
|
||
logger.error(t("console.fetchNodeDetailFailed", error=str(e)))
|
||
return None
|
||
|
||
def get_node_edges(self, graph_id: str, node_uuid: str) -> List[EdgeInfo]:
|
||
"""
|
||
Get all edges related to a node.
|
||
|
||
Args:
|
||
graph_id: Graph ID.
|
||
node_uuid: Node UUID.
|
||
|
||
Returns:
|
||
List of edges.
|
||
"""
|
||
logger.info(t("console.fetchingNodeEdges", uuid=node_uuid[:8]))
|
||
|
||
try:
|
||
store = LocalGraphStore(graph_id)
|
||
edges = store.get_node_edges(node_uuid)
|
||
|
||
result = []
|
||
for edge in edges:
|
||
edge_info = EdgeInfo(
|
||
uuid=edge.uuid or "",
|
||
name=edge.name or "",
|
||
fact=edge.fact or "",
|
||
source_node_uuid=edge.source_node_uuid or "",
|
||
target_node_uuid=edge.target_node_uuid or "",
|
||
created_at=edge.created_at,
|
||
valid_at=edge.valid_at,
|
||
invalid_at=edge.invalid_at,
|
||
expired_at=edge.expired_at,
|
||
)
|
||
result.append(edge_info)
|
||
|
||
logger.info(t("console.foundNodeEdges", count=len(result)))
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.warning(t("console.fetchNodeEdgesFailed", error=str(e)))
|
||
return []
|
||
|
||
def get_entities_by_type(self, graph_id: str, entity_type: str) -> List[NodeInfo]:
|
||
"""
|
||
Get entities by type.
|
||
|
||
Args:
|
||
graph_id: Graph ID.
|
||
entity_type: Entity type (e.g. Student, PublicFigure, etc.).
|
||
|
||
Returns:
|
||
List of entities matching the type.
|
||
"""
|
||
logger.info(t("console.fetchingEntitiesByType", type=entity_type))
|
||
|
||
all_nodes = self.get_all_nodes(graph_id)
|
||
|
||
filtered = []
|
||
for node in all_nodes:
|
||
# Check if labels contain specified type
|
||
if entity_type in node.labels:
|
||
filtered.append(node)
|
||
|
||
logger.info(
|
||
t("console.foundEntitiesByType", count=len(filtered), type=entity_type)
|
||
)
|
||
return filtered
|
||
|
||
def get_entity_summary(self, graph_id: str, entity_name: str) -> Dict[str, Any]:
|
||
"""
|
||
Get the relationship summary for a given entity.
|
||
|
||
Searches all information related to that entity and generates a summary.
|
||
|
||
Args:
|
||
graph_id: Graph ID.
|
||
entity_name: Entity name.
|
||
|
||
Returns:
|
||
Entity summary information.
|
||
"""
|
||
logger.info(t("console.fetchingEntitySummary", name=entity_name))
|
||
|
||
# First search for info related to this entity
|
||
search_result = self.search_graph(
|
||
graph_id=graph_id, query=entity_name, limit=20
|
||
)
|
||
|
||
# Try to find this entity among all nodes
|
||
all_nodes = self.get_all_nodes(graph_id)
|
||
entity_node = None
|
||
for node in all_nodes:
|
||
if node.name.lower() == entity_name.lower():
|
||
entity_node = node
|
||
break
|
||
|
||
related_edges = []
|
||
if entity_node:
|
||
# Pass graph_id parameter
|
||
related_edges = self.get_node_edges(graph_id, entity_node.uuid)
|
||
|
||
return {
|
||
"entity_name": entity_name,
|
||
"entity_info": entity_node.to_dict() if entity_node else None,
|
||
"related_facts": search_result.facts,
|
||
"related_edges": [e.to_dict() for e in related_edges],
|
||
"total_relations": len(related_edges),
|
||
}
|
||
|
||
def get_graph_statistics(self, graph_id: str) -> Dict[str, Any]:
|
||
"""
|
||
Get statistics for the graph.
|
||
|
||
Args:
|
||
graph_id: Graph ID.
|
||
|
||
Returns:
|
||
Statistics information.
|
||
"""
|
||
logger.info(t("console.fetchingGraphStats", graphId=graph_id))
|
||
|
||
nodes = self.get_all_nodes(graph_id)
|
||
edges = self.get_all_edges(graph_id)
|
||
|
||
# Entity type distribution statistics
|
||
entity_types = {}
|
||
for node in nodes:
|
||
for label in node.labels:
|
||
if label not in ["Entity", "Node"]:
|
||
entity_types[label] = entity_types.get(label, 0) + 1
|
||
|
||
# Relation type distribution statistics
|
||
relation_types = {}
|
||
for edge in edges:
|
||
relation_types[edge.name] = relation_types.get(edge.name, 0) + 1
|
||
|
||
return {
|
||
"graph_id": graph_id,
|
||
"total_nodes": len(nodes),
|
||
"total_edges": len(edges),
|
||
"entity_types": entity_types,
|
||
"relation_types": relation_types,
|
||
}
|
||
|
||
def get_simulation_context(
|
||
self, graph_id: str, simulation_requirement: str, limit: int = 30
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Get simulation-related context information.
|
||
|
||
Comprehensively searches all information related to the simulation
|
||
requirement.
|
||
|
||
Args:
|
||
graph_id: Graph ID.
|
||
simulation_requirement: Description of the simulation requirement.
|
||
limit: Per-category information count limit.
|
||
|
||
Returns:
|
||
Simulation context information.
|
||
"""
|
||
logger.info(
|
||
t("console.fetchingSimContext", requirement=simulation_requirement[:50])
|
||
)
|
||
|
||
# Search for info related to simulation requirement
|
||
search_result = self.search_graph(
|
||
graph_id=graph_id, query=simulation_requirement, limit=limit
|
||
)
|
||
|
||
# Get graph statistics
|
||
stats = self.get_graph_statistics(graph_id)
|
||
|
||
# Get all entity nodes
|
||
all_nodes = self.get_all_nodes(graph_id)
|
||
|
||
# Filter entities with actual types (not bare Entity nodes)
|
||
entities = []
|
||
for node in all_nodes:
|
||
custom_labels = [l for l in node.labels if l not in ["Entity", "Node"]]
|
||
if custom_labels:
|
||
entities.append(
|
||
{
|
||
"name": node.name,
|
||
"type": custom_labels[0],
|
||
"summary": node.summary,
|
||
}
|
||
)
|
||
|
||
return {
|
||
"simulation_requirement": simulation_requirement,
|
||
"related_facts": search_result.facts,
|
||
"graph_statistics": stats,
|
||
"entities": entities[:limit], # Limit count
|
||
"total_entities": len(entities),
|
||
}
|
||
|
||
# ========== Core Retrieval Tools (Optimized) ==========
|
||
|
||
def insight_forge(
|
||
self,
|
||
graph_id: str,
|
||
query: str,
|
||
simulation_requirement: str,
|
||
report_context: str = "",
|
||
max_sub_queries: int = 5,
|
||
) -> InsightForgeResult:
|
||
"""
|
||
[InsightForge - deep insight retrieval]
|
||
|
||
The most powerful hybrid retrieval function; automatically decomposes
|
||
the question and retrieves across multiple dimensions:
|
||
1. Use the LLM to decompose the question into multiple sub-questions.
|
||
2. Perform a semantic search for each sub-question.
|
||
3. Extract related entities and obtain their detailed information.
|
||
4. Trace relationship chains.
|
||
5. Integrate all results to generate a deep insight.
|
||
|
||
Args:
|
||
graph_id: Graph ID.
|
||
query: User question.
|
||
simulation_requirement: Description of the simulation requirement.
|
||
report_context: Report context (optional; used for more precise
|
||
sub-question generation).
|
||
max_sub_queries: Maximum number of sub-questions.
|
||
|
||
Returns:
|
||
InsightForgeResult: The deep insight retrieval result.
|
||
"""
|
||
logger.info(t("console.insightForgeStart", query=query[:50]))
|
||
|
||
result = InsightForgeResult(
|
||
query=query, simulation_requirement=simulation_requirement, sub_queries=[]
|
||
)
|
||
|
||
# Step 1: Use LLM to generate sub-queries
|
||
sub_queries = self._generate_sub_queries(
|
||
query=query,
|
||
simulation_requirement=simulation_requirement,
|
||
report_context=report_context,
|
||
max_queries=max_sub_queries,
|
||
)
|
||
result.sub_queries = sub_queries
|
||
logger.info(t("console.generatedSubQueries", count=len(sub_queries)))
|
||
|
||
# Step 2: Semantic search for each sub-query
|
||
all_facts = []
|
||
all_edges = []
|
||
seen_facts = set()
|
||
|
||
for sub_query in sub_queries:
|
||
search_result = self.search_graph(
|
||
graph_id=graph_id, query=sub_query, limit=15, scope="edges"
|
||
)
|
||
|
||
for fact in search_result.facts:
|
||
if fact not in seen_facts:
|
||
all_facts.append(fact)
|
||
seen_facts.add(fact)
|
||
|
||
all_edges.extend(search_result.edges)
|
||
|
||
# Also search the original query
|
||
main_search = self.search_graph(
|
||
graph_id=graph_id, query=query, limit=20, scope="edges"
|
||
)
|
||
for fact in main_search.facts:
|
||
if fact not in seen_facts:
|
||
all_facts.append(fact)
|
||
seen_facts.add(fact)
|
||
|
||
result.semantic_facts = all_facts
|
||
result.total_facts = len(all_facts)
|
||
|
||
# Step 3: Extract related entity UUIDs from edges, only fetch these entities (not all nodes)
|
||
entity_uuids = set()
|
||
for edge_data in all_edges:
|
||
if isinstance(edge_data, dict):
|
||
source_uuid = edge_data.get("source_node_uuid", "")
|
||
target_uuid = edge_data.get("target_node_uuid", "")
|
||
if source_uuid:
|
||
entity_uuids.add(source_uuid)
|
||
if target_uuid:
|
||
entity_uuids.add(target_uuid)
|
||
|
||
# Get details of all related entities (no limit, full output)
|
||
entity_insights = []
|
||
node_map = {} # For later relationship chain construction
|
||
|
||
for uuid in list(entity_uuids): # Process all entities, no truncation
|
||
if not uuid:
|
||
continue
|
||
try:
|
||
# Get info for each related node individually
|
||
node = self.get_node_detail(graph_id, uuid)
|
||
if node:
|
||
node_map[uuid] = node
|
||
entity_type = next(
|
||
(l for l in node.labels if l not in ["Entity", "Node"]),
|
||
"Entity",
|
||
)
|
||
|
||
# Get all facts related to this entity (no truncation)
|
||
related_facts = [
|
||
f for f in all_facts if node.name.lower() in f.lower()
|
||
]
|
||
|
||
entity_insights.append(
|
||
{
|
||
"uuid": node.uuid,
|
||
"name": node.name,
|
||
"type": entity_type,
|
||
"summary": node.summary,
|
||
"related_facts": related_facts, # Full output, no truncation
|
||
}
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"Failed to get node {uuid}: {e}")
|
||
continue
|
||
|
||
result.entity_insights = entity_insights
|
||
result.total_entities = len(entity_insights)
|
||
|
||
# Step 4: Build all relationship chains (no limit)
|
||
relationship_chains = []
|
||
for edge_data in all_edges: # Process all edges, no truncation
|
||
if isinstance(edge_data, dict):
|
||
source_uuid = edge_data.get("source_node_uuid", "")
|
||
target_uuid = edge_data.get("target_node_uuid", "")
|
||
relation_name = edge_data.get("name", "")
|
||
|
||
source_name = (
|
||
node_map.get(source_uuid, NodeInfo("", "", [], "", {})).name
|
||
or source_uuid[:8]
|
||
)
|
||
target_name = (
|
||
node_map.get(target_uuid, NodeInfo("", "", [], "", {})).name
|
||
or target_uuid[:8]
|
||
)
|
||
|
||
chain = f"{source_name} --[{relation_name}]--> {target_name}"
|
||
if chain not in relationship_chains:
|
||
relationship_chains.append(chain)
|
||
|
||
result.relationship_chains = relationship_chains
|
||
result.total_relationships = len(relationship_chains)
|
||
|
||
logger.info(
|
||
t(
|
||
"console.insightForgeComplete",
|
||
facts=result.total_facts,
|
||
entities=result.total_entities,
|
||
relationships=result.total_relationships,
|
||
)
|
||
)
|
||
return result
|
||
|
||
def _generate_sub_queries(
|
||
self,
|
||
query: str,
|
||
simulation_requirement: str,
|
||
report_context: str = "",
|
||
max_queries: int = 5,
|
||
) -> List[str]:
|
||
"""
|
||
Use the LLM to generate sub-questions.
|
||
|
||
Decomposes a complex question into multiple sub-questions that can be
|
||
retrieved independently.
|
||
"""
|
||
system_prompt = """You are a professional question analysis expert. Your task is to decompose a complex question into multiple sub-questions that can be independently observed in the simulation world.
|
||
|
||
Requirements:
|
||
1. Each sub-question should be specific enough to find related Agent behaviors or events in the simulation world
|
||
2. Sub-questions should cover different dimensions of the original question (e.g.: who, what, why, how, when, where)
|
||
3. Sub-questions should be relevant to the simulation scenario
|
||
4. Return JSON format: {"sub_queries": ["sub-question1", "sub-question2", ...]}"""
|
||
|
||
user_prompt = f"""Simulation requirement background:
|
||
{simulation_requirement}
|
||
|
||
{f"Report context: {report_context[:500]}" if report_context else ""}
|
||
|
||
Please decompose the following question into {max_queries} sub-questions:
|
||
{query}
|
||
|
||
Return the list of sub-questions in JSON format."""
|
||
|
||
try:
|
||
response = self.llm.chat_json(
|
||
messages=[
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
],
|
||
temperature=0.3,
|
||
)
|
||
|
||
sub_queries = response.get("sub_queries", [])
|
||
# Ensure it's a list of strings
|
||
return [str(sq) for sq in sub_queries[:max_queries]]
|
||
|
||
except Exception as e:
|
||
logger.warning(t("console.generateSubQueriesFailed", error=str(e)))
|
||
# Fallback: return variants based on original query
|
||
return [
|
||
query,
|
||
f"Main participants of {query}",
|
||
f"Causes and impacts of {query}",
|
||
f"Development process of {query}",
|
||
][:max_queries]
|
||
|
||
def panorama_search(
|
||
self, graph_id: str, query: str, include_expired: bool = True, limit: int = 50
|
||
) -> PanoramaResult:
|
||
"""
|
||
[PanoramaSearch - breadth search]
|
||
|
||
Gets the full-picture view, including all related content and
|
||
historical/expired information:
|
||
1. Get all related nodes.
|
||
2. Get all edges (including expired/invalid ones).
|
||
3. Categorize and organize current and historical information.
|
||
|
||
This tool is suitable for scenarios where you need to understand the
|
||
full picture of an event or trace its evolution.
|
||
|
||
Args:
|
||
graph_id: Graph ID.
|
||
query: Search query (used for relevance ranking).
|
||
include_expired: Whether to include expired content (default True).
|
||
limit: Return result count limit.
|
||
|
||
Returns:
|
||
PanoramaResult: The breadth search result.
|
||
"""
|
||
logger.info(t("console.panoramaSearchStart", query=query[:50]))
|
||
|
||
result = PanoramaResult(query=query)
|
||
|
||
# Get all nodes
|
||
all_nodes = self.get_all_nodes(graph_id)
|
||
node_map = {n.uuid: n for n in all_nodes}
|
||
result.all_nodes = all_nodes
|
||
result.total_nodes = len(all_nodes)
|
||
|
||
# Get all edges (with time info)
|
||
all_edges = self.get_all_edges(graph_id, include_temporal=True)
|
||
result.all_edges = all_edges
|
||
result.total_edges = len(all_edges)
|
||
|
||
# Classify facts
|
||
active_facts = []
|
||
historical_facts = []
|
||
|
||
for edge in all_edges:
|
||
if not edge.fact:
|
||
continue
|
||
|
||
# Add entity names to facts
|
||
source_name = (
|
||
node_map.get(edge.source_node_uuid, NodeInfo("", "", [], "", {})).name
|
||
or edge.source_node_uuid[:8]
|
||
)
|
||
target_name = (
|
||
node_map.get(edge.target_node_uuid, NodeInfo("", "", [], "", {})).name
|
||
or edge.target_node_uuid[:8]
|
||
)
|
||
|
||
# Determine if expired/invalid
|
||
is_historical = edge.is_expired or edge.is_invalid
|
||
|
||
if is_historical:
|
||
# Historical/expired facts, add time marker
|
||
valid_at = edge.valid_at or t("api.unknown")
|
||
invalid_at = edge.invalid_at or edge.expired_at or t("api.unknown")
|
||
fact_with_time = f"[{valid_at} - {invalid_at}] {edge.fact}"
|
||
historical_facts.append(fact_with_time)
|
||
else:
|
||
# Currently active facts
|
||
active_facts.append(edge.fact)
|
||
|
||
# Relevance sort based on query
|
||
query_lower = query.lower()
|
||
keywords = [
|
||
w.strip()
|
||
for w in query_lower.replace(",", " ").replace(",", " ").split()
|
||
if len(w.strip()) > 1
|
||
]
|
||
|
||
def relevance_score(fact: str) -> int:
|
||
fact_lower = fact.lower()
|
||
score = 0
|
||
if query_lower in fact_lower:
|
||
score += 100
|
||
for kw in keywords:
|
||
if kw in fact_lower:
|
||
score += 10
|
||
return score
|
||
|
||
# Sort and limit count
|
||
active_facts.sort(key=relevance_score, reverse=True)
|
||
historical_facts.sort(key=relevance_score, reverse=True)
|
||
|
||
result.active_facts = active_facts[:limit]
|
||
result.historical_facts = historical_facts[:limit] if include_expired else []
|
||
result.active_count = len(active_facts)
|
||
result.historical_count = len(historical_facts)
|
||
|
||
logger.info(
|
||
t(
|
||
"console.panoramaSearchComplete",
|
||
active=result.active_count,
|
||
historical=result.historical_count,
|
||
)
|
||
)
|
||
return result
|
||
|
||
def quick_search(self, graph_id: str, query: str, limit: int = 10) -> SearchResult:
|
||
"""
|
||
[QuickSearch - simple search]
|
||
|
||
A fast, lightweight retrieval tool:
|
||
1. Directly calls graph semantic search.
|
||
2. Returns the most relevant results.
|
||
3. Suitable for simple, direct retrieval needs.
|
||
|
||
Args:
|
||
graph_id: Graph ID.
|
||
query: Search query.
|
||
limit: Number of results to return.
|
||
|
||
Returns:
|
||
SearchResult: The search result.
|
||
"""
|
||
logger.info(t("console.quickSearchStart", query=query[:50]))
|
||
|
||
# Directly call existing search_graph method
|
||
result = self.search_graph(
|
||
graph_id=graph_id, query=query, limit=limit, scope="edges"
|
||
)
|
||
|
||
logger.info(t("console.quickSearchComplete", count=result.total_count))
|
||
return result
|
||
|
||
def interview_agents(
|
||
self,
|
||
simulation_id: str,
|
||
interview_requirement: str,
|
||
simulation_requirement: str = "",
|
||
max_agents: int = 5,
|
||
custom_questions: List[str] = None,
|
||
) -> InterviewResult:
|
||
"""
|
||
[InterviewAgents - deep interview]
|
||
|
||
Calls the real OASIS interview API to interview Agents that are
|
||
currently running in the simulation:
|
||
1. Automatically read persona files to understand all simulated Agents.
|
||
2. Use the LLM to analyze the interview requirement and intelligently
|
||
select the most relevant Agents.
|
||
3. Use the LLM to generate interview questions.
|
||
4. Call the /api/simulation/interview/batch interface to conduct real
|
||
interviews (interviewing both platforms simultaneously).
|
||
5. Integrate all interview results to generate an interview report.
|
||
|
||
[Important] This feature requires the simulation environment to be
|
||
running (the OASIS environment must not be closed).
|
||
|
||
[Use cases]
|
||
- Need to understand views on an event from different role perspectives.
|
||
- Need to collect multiple opinions and standpoints.
|
||
- Need to obtain real answers from simulated Agents (not LLM simulation).
|
||
|
||
Args:
|
||
simulation_id: Simulation ID (used to locate persona files and call
|
||
the interview API).
|
||
interview_requirement: Interview requirement description (unstructured,
|
||
e.g. "Understand students' views on the incident").
|
||
simulation_requirement: Simulation requirement background (optional).
|
||
max_agents: Maximum number of Agents to interview.
|
||
custom_questions: Custom interview questions (optional; auto-generated
|
||
if not provided).
|
||
|
||
Returns:
|
||
InterviewResult: The interview result.
|
||
"""
|
||
from .simulation_runner import SimulationRunner
|
||
|
||
logger.info(
|
||
t("console.interviewAgentsStart", requirement=interview_requirement[:50])
|
||
)
|
||
|
||
result = InterviewResult(
|
||
interview_topic=interview_requirement,
|
||
interview_questions=custom_questions or [],
|
||
)
|
||
|
||
# Step 1: Read persona files
|
||
profiles = self._load_agent_profiles(simulation_id)
|
||
|
||
if not profiles:
|
||
logger.warning(t("console.profilesNotFound", simId=simulation_id))
|
||
result.summary = "No agent persona files available for interview found"
|
||
return result
|
||
|
||
result.total_agents = len(profiles)
|
||
logger.info(t("console.loadedProfiles", count=len(profiles)))
|
||
|
||
# Step 2: Use LLM to select Agents to interview (returns agent_id list)
|
||
selected_agents, selected_indices, selection_reasoning = (
|
||
self._select_agents_for_interview(
|
||
profiles=profiles,
|
||
interview_requirement=interview_requirement,
|
||
simulation_requirement=simulation_requirement,
|
||
max_agents=max_agents,
|
||
)
|
||
)
|
||
|
||
result.selected_agents = selected_agents
|
||
result.selection_reasoning = selection_reasoning
|
||
logger.info(
|
||
t(
|
||
"console.selectedAgentsForInterview",
|
||
count=len(selected_agents),
|
||
indices=selected_indices,
|
||
)
|
||
)
|
||
|
||
# Step 3: Generate interview questions (if not provided)
|
||
if not result.interview_questions:
|
||
result.interview_questions = self._generate_interview_questions(
|
||
interview_requirement=interview_requirement,
|
||
simulation_requirement=simulation_requirement,
|
||
selected_agents=selected_agents,
|
||
)
|
||
logger.info(
|
||
t(
|
||
"console.generatedInterviewQuestions",
|
||
count=len(result.interview_questions),
|
||
)
|
||
)
|
||
|
||
# Merge questions into one interview prompt
|
||
combined_prompt = "\n".join(
|
||
[f"{i + 1}. {q}" for i, q in enumerate(result.interview_questions)]
|
||
)
|
||
|
||
# Add optimization prefix to constrain Agent reply format
|
||
INTERVIEW_PROMPT_PREFIX = (
|
||
"You are being interviewed. Please draw on your persona, all past memories and actions, "
|
||
"and answer the following questions directly in plain text.\n"
|
||
"Response requirements:\n"
|
||
"1. Answer directly in natural language, do not call any tools\n"
|
||
"2. Do not return JSON format or tool-call format\n"
|
||
"3. Do not use Markdown headings (e.g. #, ##, ###)\n"
|
||
"4. Answer each question in turn, starting each answer with 'Question X:' (X is the question number)\n"
|
||
"5. Separate answers to each question with a blank line\n"
|
||
"6. Answers should be substantive, with at least 2-3 sentences per question\n\n"
|
||
)
|
||
optimized_prompt = f"{INTERVIEW_PROMPT_PREFIX}{combined_prompt}"
|
||
|
||
# Step 4: Call real interview API (no platform specified, default dual-platform interview)
|
||
try:
|
||
# Build batch interview list (no platform specified, dual-platform interview)
|
||
interviews_request = []
|
||
for agent_idx in selected_indices:
|
||
interviews_request.append(
|
||
{
|
||
"agent_id": agent_idx,
|
||
"prompt": optimized_prompt, # Use optimized prompt
|
||
# No platform specified, API will interview on both twitter and reddit
|
||
}
|
||
)
|
||
|
||
logger.info(
|
||
t("console.callingBatchInterviewApi", count=len(interviews_request))
|
||
)
|
||
|
||
# Call SimulationRunner batch interview method (no platform, dual-platform interview)
|
||
api_result = SimulationRunner.interview_agents_batch(
|
||
simulation_id=simulation_id,
|
||
interviews=interviews_request,
|
||
platform=None, # No platform specified, dual-platform interview
|
||
timeout=180.0, # Dual platform needs longer timeout
|
||
)
|
||
|
||
logger.info(
|
||
t(
|
||
"console.interviewApiReturned",
|
||
count=api_result.get("interviews_count", 0),
|
||
success=api_result.get("success"),
|
||
)
|
||
)
|
||
|
||
# Check if API call succeeded
|
||
if not api_result.get("success", False):
|
||
error_msg = api_result.get("error", t("api.unknownError"))
|
||
logger.warning(
|
||
t("console.interviewApiReturnedFailure", error=error_msg)
|
||
)
|
||
result.summary = f"Interview API call failed: {error_msg}. Please check the OASIS simulation environment status."
|
||
return result
|
||
|
||
# Step 5: Parse API results, build AgentInterview objects
|
||
# Dual-platform return format: {"twitter_0": {...}, "reddit_0": {...}, "twitter_1": {...}, ...}
|
||
api_data = api_result.get("result", {})
|
||
results_dict = (
|
||
api_data.get("results", {}) if isinstance(api_data, dict) else {}
|
||
)
|
||
|
||
for i, agent_idx in enumerate(selected_indices):
|
||
agent = selected_agents[i]
|
||
agent_name = agent.get(
|
||
"realname", agent.get("username", f"Agent_{agent_idx}")
|
||
)
|
||
agent_role = agent.get("profession", t("api.unknown"))
|
||
agent_bio = agent.get("bio", "")
|
||
|
||
# Get this Agent's interview results from both platforms
|
||
twitter_result = results_dict.get(f"twitter_{agent_idx}", {})
|
||
reddit_result = results_dict.get(f"reddit_{agent_idx}", {})
|
||
|
||
twitter_response = twitter_result.get("response", "")
|
||
reddit_response = reddit_result.get("response", "")
|
||
|
||
# Clean possible tool call JSON wrapper
|
||
twitter_response = self._clean_tool_call_response(twitter_response)
|
||
reddit_response = self._clean_tool_call_response(reddit_response)
|
||
|
||
# Always output dual-platform markers
|
||
twitter_text = (
|
||
twitter_response
|
||
if twitter_response
|
||
else "(No response was obtained from this platform)"
|
||
)
|
||
reddit_text = (
|
||
reddit_response
|
||
if reddit_response
|
||
else "(No response was obtained from this platform)"
|
||
)
|
||
response_text = f"[Twitter Platform Response]\n{twitter_text}\n\n[Reddit Platform Response]\n{reddit_text}"
|
||
|
||
# Extract key quotes (from both platform responses)
|
||
import re
|
||
|
||
combined_responses = f"{twitter_response} {reddit_response}"
|
||
|
||
# Clean response text: remove markers, numbering, Markdown noise
|
||
clean_text = re.sub(r"#{1,6}\s+", "", combined_responses)
|
||
clean_text = re.sub(r"\{[^}]*tool_name[^}]*\}", "", clean_text)
|
||
clean_text = re.sub(r"[*_`|>~\-]{2,}", "", clean_text)
|
||
clean_text = re.sub(
|
||
r"(Question|Question)\s*\d+\s*[::]\s*",
|
||
"",
|
||
clean_text,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
clean_text = re.sub(r"【[^】]+】", "", clean_text)
|
||
clean_text = re.sub(r"\[[^\]]+\]", "", clean_text)
|
||
|
||
# Strategy 1 (primary): extract complete substantive sentences
|
||
sentences = re.split(r"[。!?]", clean_text)
|
||
meaningful = [
|
||
s.strip()
|
||
for s in sentences
|
||
if 20 <= len(s.strip()) <= 150
|
||
and not re.match(r"^[\s\W,,;;::、]+", s.strip())
|
||
and not s.strip().startswith(("{", "Question", "Question"))
|
||
]
|
||
meaningful.sort(key=len, reverse=True)
|
||
key_quotes = [s + "。" for s in meaningful[:3]]
|
||
|
||
# Strategy 2 (supplement): correctly paired Chinese quote marks「」long text
|
||
if not key_quotes:
|
||
paired = re.findall(
|
||
r"\u201c([^\u201c\u201d]{15,100})\u201d", clean_text
|
||
)
|
||
paired += re.findall(
|
||
r"\u300c([^\u300c\u300d]{15,100})\u300d", clean_text
|
||
)
|
||
key_quotes = [
|
||
q for q in paired if not re.match(r"^[,,;;::、]", q)
|
||
][:3]
|
||
|
||
interview = AgentInterview(
|
||
agent_name=agent_name,
|
||
agent_role=agent_role,
|
||
agent_bio=agent_bio[:1000], # Expand bio length limit
|
||
question=combined_prompt,
|
||
response=response_text,
|
||
key_quotes=key_quotes[:5],
|
||
)
|
||
result.interviews.append(interview)
|
||
|
||
result.interviewed_count = len(result.interviews)
|
||
|
||
except ValueError as e:
|
||
# Simulation environment not running
|
||
logger.warning(t("console.interviewApiCallFailed", error=e))
|
||
result.summary = f"Interview failed: {str(e)}. The simulation environment may be closed; please ensure the OASIS environment is running."
|
||
return result
|
||
except Exception as e:
|
||
logger.error(t("console.interviewApiCallException", error=e))
|
||
import traceback
|
||
|
||
logger.error(traceback.format_exc())
|
||
result.summary = f"An error occurred during the interview: {str(e)}"
|
||
return result
|
||
|
||
# Step 6: Generate interview summary
|
||
if result.interviews:
|
||
result.summary = self._generate_interview_summary(
|
||
interviews=result.interviews,
|
||
interview_requirement=interview_requirement,
|
||
)
|
||
|
||
logger.info(
|
||
t("console.interviewAgentsComplete", count=result.interviewed_count)
|
||
)
|
||
return result
|
||
|
||
@staticmethod
|
||
def _clean_tool_call_response(response: str) -> str:
|
||
"""Clean the JSON tool-call wrapper in an Agent reply and extract the actual content."""
|
||
if not response or not response.strip().startswith("{"):
|
||
return response
|
||
text = response.strip()
|
||
if "tool_name" not in text[:80]:
|
||
return response
|
||
import re as _re
|
||
|
||
try:
|
||
data = json.loads(text)
|
||
if isinstance(data, dict) and "arguments" in data:
|
||
for key in ("content", "text", "body", "message", "reply"):
|
||
if key in data["arguments"]:
|
||
return str(data["arguments"][key])
|
||
except (json.JSONDecodeError, KeyError, TypeError):
|
||
match = _re.search(r'"content"\s*:\s*"((?:[^"\\]|\\.)*)"', text)
|
||
if match:
|
||
return match.group(1).replace("\\n", "\n").replace('\\"', '"')
|
||
return response
|
||
|
||
def _load_agent_profiles(self, simulation_id: str) -> List[Dict[str, Any]]:
|
||
"""Load the simulated Agent persona files."""
|
||
import os
|
||
import csv
|
||
|
||
# Build persona file paths
|
||
sim_dir = os.path.join(
|
||
os.path.dirname(__file__), f"../../uploads/simulations/{simulation_id}"
|
||
)
|
||
|
||
profiles = []
|
||
|
||
# Try reading Reddit JSON format first
|
||
reddit_profile_path = os.path.join(sim_dir, "reddit_profiles.json")
|
||
if os.path.exists(reddit_profile_path):
|
||
try:
|
||
with open(reddit_profile_path, "r", encoding="utf-8") as f:
|
||
profiles = json.load(f)
|
||
logger.info(t("console.loadedRedditProfiles", count=len(profiles)))
|
||
return profiles
|
||
except Exception as e:
|
||
logger.warning(t("console.readRedditProfilesFailed", error=e))
|
||
|
||
# Try reading Twitter CSV format
|
||
twitter_profile_path = os.path.join(sim_dir, "twitter_profiles.csv")
|
||
if os.path.exists(twitter_profile_path):
|
||
try:
|
||
with open(twitter_profile_path, "r", encoding="utf-8") as f:
|
||
reader = csv.DictReader(f)
|
||
for row in reader:
|
||
# Convert CSV format to unified format
|
||
profiles.append(
|
||
{
|
||
"realname": row.get("name", ""),
|
||
"username": row.get("username", ""),
|
||
"bio": row.get("description", ""),
|
||
"persona": row.get("user_char", ""),
|
||
"profession": t("api.unknown"),
|
||
}
|
||
)
|
||
logger.info(t("console.loadedTwitterProfiles", count=len(profiles)))
|
||
return profiles
|
||
except Exception as e:
|
||
logger.warning(t("console.readTwitterProfilesFailed", error=e))
|
||
|
||
return profiles
|
||
|
||
def _select_agents_for_interview(
|
||
self,
|
||
profiles: List[Dict[str, Any]],
|
||
interview_requirement: str,
|
||
simulation_requirement: str,
|
||
max_agents: int,
|
||
) -> tuple:
|
||
"""
|
||
Use the LLM to select the Agents to interview.
|
||
|
||
Returns:
|
||
tuple: (selected_agents, selected_indices, reasoning)
|
||
- selected_agents: list of full info for selected Agents.
|
||
- selected_indices: list of indices for selected Agents (used for API calls).
|
||
- reasoning: selection reasoning.
|
||
"""
|
||
|
||
# Build Agent summary list
|
||
agent_summaries = []
|
||
for i, profile in enumerate(profiles):
|
||
summary = {
|
||
"index": i,
|
||
"name": profile.get("realname", profile.get("username", f"Agent_{i}")),
|
||
"profession": profile.get("profession", t("api.unknown")),
|
||
"bio": profile.get("bio", "")[:200],
|
||
"interested_topics": profile.get("interested_topics", []),
|
||
}
|
||
agent_summaries.append(summary)
|
||
|
||
system_prompt = """You are a professional interview planning expert. Your task is to select the most suitable interview subjects from the list of simulation Agents based on the interview requirement.
|
||
|
||
Selection criteria:
|
||
1. The Agent's identity/profession is relevant to the interview topic
|
||
2. The Agent may hold unique or valuable viewpoints
|
||
3. Select diverse perspectives (e.g.: supporters, opponents, neutrals, professionals, etc.)
|
||
4. Prioritize roles directly related to the event
|
||
|
||
Return JSON format:
|
||
{
|
||
"selected_indices": [list of selected Agent indices],
|
||
"reasoning": "explanation of selection reasoning"
|
||
}"""
|
||
|
||
user_prompt = f"""Interview requirement:
|
||
{interview_requirement}
|
||
|
||
Simulation background:
|
||
{simulation_requirement if simulation_requirement else "Not provided"}
|
||
|
||
List of selectable Agents (total {len(agent_summaries)}):
|
||
{json.dumps(agent_summaries, ensure_ascii=False, indent=2)}
|
||
|
||
Please select up to {max_agents} most suitable Agents to interview, and explain the selection reasoning."""
|
||
|
||
try:
|
||
response = self.llm.chat_json(
|
||
messages=[
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
],
|
||
temperature=0.3,
|
||
)
|
||
|
||
selected_indices = response.get("selected_indices", [])[:max_agents]
|
||
reasoning = response.get("reasoning", "Auto-selected based on relevance")
|
||
|
||
# Get full info for selected Agents
|
||
selected_agents = []
|
||
valid_indices = []
|
||
for idx in selected_indices:
|
||
if 0 <= idx < len(profiles):
|
||
selected_agents.append(profiles[idx])
|
||
valid_indices.append(idx)
|
||
|
||
return selected_agents, valid_indices, reasoning
|
||
|
||
except Exception as e:
|
||
logger.warning(t("console.llmSelectAgentFailed", error=e))
|
||
# Fallback: select first N
|
||
selected = profiles[:max_agents]
|
||
indices = list(range(min(max_agents, len(profiles))))
|
||
return selected, indices, "Using default selection strategy"
|
||
|
||
def _generate_interview_questions(
|
||
self,
|
||
interview_requirement: str,
|
||
simulation_requirement: str,
|
||
selected_agents: List[Dict[str, Any]],
|
||
) -> List[str]:
|
||
"""Use the LLM to generate interview questions."""
|
||
|
||
agent_roles = [a.get("profession", t("api.unknown")) for a in selected_agents]
|
||
|
||
system_prompt = """You are a professional journalist/interviewer. Based on the interview requirement, generate 3-5 in-depth interview questions.
|
||
|
||
Question requirements:
|
||
1. Open-ended questions that encourage detailed answers
|
||
2. May yield different answers for different roles
|
||
3. Cover multiple dimensions such as facts, opinions, feelings, etc.
|
||
4. Natural language, like a real interview
|
||
5. Keep each question within 50 words, concise and clear
|
||
6. Ask directly, do not include background explanation or prefixes
|
||
|
||
Return JSON format: {"questions": ["question1", "question2", ...]}"""
|
||
|
||
user_prompt = f"""Interview requirement: {interview_requirement}
|
||
|
||
Simulation background: {simulation_requirement if simulation_requirement else "Not provided"}
|
||
|
||
Interviewee roles: {", ".join(agent_roles)}
|
||
|
||
Please generate 3-5 interview questions."""
|
||
|
||
try:
|
||
response = self.llm.chat_json(
|
||
messages=[
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
],
|
||
temperature=0.5,
|
||
)
|
||
|
||
return response.get(
|
||
"questions", [f"What are your views on {interview_requirement}?"]
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.warning(t("console.generateInterviewQuestionsFailed", error=e))
|
||
return [
|
||
f"What is your opinion on {interview_requirement}?",
|
||
"What impact does this matter have on you or the group you represent?",
|
||
"How do you think this problem should be solved or improved?",
|
||
]
|
||
|
||
def _generate_interview_summary(
|
||
self, interviews: List[AgentInterview], interview_requirement: str
|
||
) -> str:
|
||
"""Generate an interview summary."""
|
||
|
||
if not interviews:
|
||
return "No interviews were completed"
|
||
|
||
# Collect all interview content
|
||
interview_texts = []
|
||
for interview in interviews:
|
||
interview_texts.append(
|
||
f"[{interview.agent_name} ({interview.agent_role})]\n{interview.response[:500]}"
|
||
)
|
||
|
||
quote_instruction = (
|
||
"Use Chinese quote marks「」when quoting interviewees"
|
||
if get_locale() == "zh" # kept for backward compat with Chinese quote marks in interview content
|
||
else "Use quotation marks to quote interviewees."
|
||
)
|
||
system_prompt = f"""You are a professional news editor. Based on the answers of multiple interviewees, generate an interview summary.
|
||
|
||
Summary requirements:
|
||
1. Distill the main viewpoints of each party
|
||
2. Point out consensus and disagreements among viewpoints
|
||
3. Highlight valuable quotes
|
||
4. Objective and neutral, not favoring any party
|
||
5. Keep within 1000 words
|
||
|
||
Format constraints (must be followed):
|
||
- Use plain text paragraphs, separate different parts with blank lines
|
||
- Do not use Markdown headings (e.g. #, ##, ###)
|
||
- Do not use horizontal rules (e.g. ---, ***)
|
||
- {quote_instruction}
|
||
- You may use **bold** to mark key words, but do not use other Markdown syntax"""
|
||
|
||
user_prompt = f"""Interview topic: {interview_requirement}
|
||
|
||
Interview content:
|
||
{"".join(interview_texts)}
|
||
|
||
Please generate an interview summary."""
|
||
|
||
try:
|
||
summary = self.llm.chat(
|
||
messages=[
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
],
|
||
temperature=0.3,
|
||
max_tokens=800,
|
||
)
|
||
return summary
|
||
|
||
except Exception as e:
|
||
logger.warning(t("console.generateInterviewSummaryFailed", error=e))
|
||
# Fallback: simple concatenation
|
||
return (
|
||
f"A total of {len(interviews)} interviewees were interviewed, including: "
|
||
+ ", ".join([i.agent_name for i in interviews])
|
||
)
|