MicroFish/backend/app/services/graph_tools.py

1746 lines
63 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.

"""
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])
)