참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

View File

@@ -0,0 +1,31 @@
"""Agent module for OntoCast.
This module provides a collection of agents that handle various aspects of ontology
processing, including document conversion, text chunking, fact aggregation, and
ontology management. Each agent is designed to perform a specific task in the
ontology processing pipeline.
"""
from .chunk_text import chunk_text
from .convert_document import convert_document
from .criticise_facts import criticise_facts
from .criticise_ontology import criticise_ontology
from .render_facts import render_facts, render_facts_fresh
from .render_ontology import render_ontology, render_ontology_fresh
from .select_ontology import select_ontology
from .serialize import serialize
from .sublimate_ontology import sublimate_ontology
__all__ = [
"chunk_text",
"convert_document",
"criticise_facts",
"criticise_ontology",
"render_facts",
"render_ontology",
"select_ontology",
"serialize",
"sublimate_ontology",
"render_ontology_fresh",
"render_facts_fresh",
]

View File

@@ -0,0 +1,61 @@
"""Text chunking agent for OntoCast.
This module provides functionality for splitting text into manageable chunks
that can be processed independently, ensuring optimal processing of large
documents.
"""
import logging
from ontocast.onto.content_unit import ContentUnit
from ontocast.onto.enum import Status
from ontocast.onto.state import AgentState
from ontocast.toolbox import ToolBox
logger = logging.getLogger(__name__)
def chunk_text(state: AgentState, tools: ToolBox) -> AgentState:
"""Split text into manageable chunks.
This function takes the converted document text and splits it into smaller,
manageable chunks that can be processed independently.
Args:
state: The current agent state containing the text to chunk.
tools: The toolbox instance providing utility functions.
Returns:
AgentState: Updated state with text chunks.
"""
logger.info("Chunking the text")
if state.input_text is not None:
chunks_txt: list[str] = tools.chunker(state.input_text)
logger.info(
f"Created {len(chunks_txt)} chunks for processing: {[len(c) for c in chunks_txt]}"
)
if state.max_chunks is not None:
logger.info(f"Selecting {state.max_chunks} chunks")
chunks_txt = chunks_txt[: state.max_chunks]
for i, chunk_txt in enumerate(chunks_txt):
state.content_units.append(
ContentUnit(
text=chunk_txt,
index=i,
doc_iri=state.doc_iri,
)
)
logger.info(
"Created "
f"{len(state.content_units)} content units for processing: "
f"{[len(c) for c in state.content_units]}"
)
state.status = Status.SUCCESS
else:
state.status = Status.FAILED
return state

View File

@@ -0,0 +1,151 @@
import logging
from typing import Any, TypeVar
from langchain_core.output_parsers import BaseOutputParser
from langchain_core.prompts import BasePromptTemplate
from ontocast.onto.enum import WorkflowNode
from ontocast.onto.model import Suggestions
from ontocast.prompt.common import (
suggestion_concrete_template,
suggestion_general_template,
)
from ontocast.prompt.render_facts import (
improvement_instruction_template as facts_template,
)
from ontocast.prompt.render_ontology import (
improvement_instruction_template as ontology_template,
)
from ontocast.tool import LLMTool
logger = logging.getLogger(__name__)
T = TypeVar("T")
def render_suggestions_prompt(suggestions: Suggestions, stage: WorkflowNode) -> str:
"""Generate prompt templates from the suggestions.
Returns:
Combined string with general and concrete templates.
Returns empty string if both fields are empty.
"""
# Generate general template if systemic_critique_summary is not empty
general_template = ""
if suggestions.systemic_critique_summary.strip():
general_template = suggestion_general_template.format(
general_suggestion=suggestions.systemic_critique_summary
)
concrete_template = ""
if suggestions.actionable_fixes:
# Generate concrete template if actionable_fixes is not empty
concrete_template = suggestion_concrete_template.format(
suggestion_str=suggestions.to_markdown()
)
if stage == WorkflowNode.TEXT_TO_FACTS:
template = facts_template
elif stage == WorkflowNode.TEXT_TO_ONTOLOGY:
template = ontology_template
else:
raise ValueError(f"Stage {stage} not supported")
if general_template or concrete_template:
final_prompt = template.format(
suggestions_instruction=f"\n\n{general_template}\n\n{concrete_template}"
)
else:
final_prompt = ""
return final_prompt
async def call_llm_with_retry(
llm_tool: LLMTool,
prompt: BasePromptTemplate,
parser: BaseOutputParser[T],
prompt_kwargs: dict[str, Any],
max_retries: int = 3,
retry_error_feedback: bool = True,
) -> T:
"""Call LLM and parse response with automatic retry on parsing failures.
This utility function implements a common pattern across agent functions:
1. Call LLM with a prompt
2. Parse the response
3. Retry if parsing fails (up to max_retries times)
On retry, if retry_error_feedback is True, the error message from the previous
attempt is included in the prompt to help the LLM correct its output format.
Args:
llm_tool: The LLM tool instance to use for generation.
prompt: The prompt template to format and send to the LLM.
parser: The output parser to parse the LLM response.
prompt_kwargs: Keyword arguments to pass to prompt.format_prompt().
max_retries: Maximum number of retry attempts (default: 3).
retry_error_feedback: Whether to include error feedback in retry prompts (default: True).
Returns:
The parsed output of type T.
Raises:
Exception: If parsing fails after all retry attempts, raises the last parsing error.
"""
last_error: Exception | None = None
last_sanitized_content: str | None = None
original_format_instructions = prompt_kwargs.get("format_instructions", "")
for attempt in range(max_retries):
try:
# Create a copy of prompt_kwargs for this attempt
attempt_kwargs = prompt_kwargs.copy()
# On retry, add error feedback to help LLM correct format
if attempt > 0 and retry_error_feedback and last_error is not None:
# Use sanitized content in error feedback for consistency
feedback_content = (
last_sanitized_content if last_sanitized_content else ""
)
error_feedback = (
f"\n\nIMPORTANT: The previous attempt failed to parse the response. "
f"Error: {str(last_error)}\n"
f"Previous response (for reference):\n{feedback_content}\n\n"
f"Please ensure your response strictly follows the format instructions "
f"and does not contain any control characters or invalid syntax."
)
# Add error feedback to format_instructions if present
if "format_instructions" in attempt_kwargs:
attempt_kwargs["format_instructions"] = (
original_format_instructions + error_feedback
)
else:
# If no format_instructions, add as a new field
attempt_kwargs["parsing_error_feedback"] = error_feedback
# Call LLM
response = await llm_tool(prompt.format_prompt(**attempt_kwargs))
content_to_parse = response.content
parsed = parser.parse(content_to_parse)
logger.debug(
f"Successfully parsed LLM response on attempt {attempt + 1}/{max_retries}"
)
return parsed
except Exception as e:
last_error = e
logger.warning(
f"Failed to parse LLM response on attempt {attempt + 1}/{max_retries}: {str(e)}"
)
# If this was the last attempt, raise the error
if attempt == max_retries - 1:
logger.error(
f"Failed to parse LLM response after {max_retries} attempts. "
f"Last error: {str(e)}"
)
raise
# This should never be reached, but type checker needs it
raise RuntimeError("Unexpected error in call_llm_with_retry")

View File

@@ -0,0 +1,82 @@
"""Document conversion agent for OntoCast.
This module provides functionality for converting various document formats into
structured data that can be processed by the OntoCast system.
"""
import json
import logging
import pathlib
from ontocast.onto.enum import Status
from ontocast.onto.state import AgentState
from ontocast.toolbox import ToolBox
logger = logging.getLogger(__name__)
def convert_document(state: AgentState, tools: ToolBox) -> AgentState:
"""Convert a document into structured data.
This function takes a document and converts it into a structured format that
can be processed by the OntoCast system.
Args:
state: The current agent state containing the document to convert.
tools: The toolbox instance providing utility functions.
Returns:
AgentState: Updated state with converted document data.
"""
logger.debug("Converting documents. NB: processing only one file")
state.status = Status.SUCCESS
files = state.files
for filename, file_content in files.items():
file_extension = pathlib.Path(filename).suffix.lower()
# if file_content is None:
# try:
# with open(filename, "rb") as f:
# file_content = f.read()
# if file_extension == ".json":
# file_content = json.loads(file_content)
# except Exception as e:
# logger.error(f"Failed to load file {filename}: {str(e)}")
# state.status = Status.FAILED
# return state
logger.debug(f"file ext: {file_extension}, {filename}")
if file_extension in tools.converter.supported_extensions:
logger.debug("will apply convert :")
result = tools.converter(file_content)
elif file_extension == ".json":
result = json.loads(file_content.decode("utf-8"))
# Extract user instructions from JSON if present
ontology_user_instruction = result.get("ontology_user_instruction", "")
facts_user_instruction = result.get("facts_user_instruction", "")
# Update state with user instructions
if ontology_user_instruction:
state.ontology_user_instruction = ontology_user_instruction
logger.debug(
f"Set ontology user instruction: {ontology_user_instruction}"
)
if facts_user_instruction:
state.facts_user_instruction = facts_user_instruction
logger.debug(f"Set facts user instruction: {facts_user_instruction}")
# Extract source URL from JSON if present (for provenance tracking)
source_url = result.get("url", None)
if source_url:
state.source_url = source_url
logger.debug(f"Extracted source URL from JSON: {source_url}")
elif file_extension == ".txt":
result = {"text": json.loads(file_content.decode("utf-8"))}
else:
state.status = Status.FAILED
return state
state.set_text(result["text"])
return state

View File

@@ -0,0 +1,134 @@
"""Enhanced fact criticism agent with memory and SPARQL operations.
This module provides enhanced functionality for analyzing and validating facts
with SPARQL operation support.
"""
import logging
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import PromptTemplate
from ontocast.agent.common import call_llm_with_retry
from ontocast.onto.enum import FailureStage, Status, WorkflowNode
from ontocast.onto.model import FactsCritiqueReport, Suggestions
from ontocast.onto.unit_states import UnitFactsState
from ontocast.prompt.common import (
facts_template,
ontology_template,
text_template,
user_template,
)
from ontocast.prompt.criticise_facts import (
evaluation_instruction,
preamble,
template_prompt,
)
from ontocast.tool.atomic import AtomicToolBox
logger = logging.getLogger(__name__)
async def criticise_facts(
state: UnitFactsState, tools: AtomicToolBox
) -> UnitFactsState:
"""Enhanced criticize facts with SPARQL operations.
This function performs a critical analysis of the facts in the current content unit,
with SPARQL operation support.
Args:
state: The current unit facts state containing the chunk to analyze.
tools: The toolbox instance providing utility functions.
Returns:
UnitFactsState: Updated state with analysis results.
"""
if not state.content_unit:
logger.warning("No current content unit to analyze")
return state
progress_info = state.get_content_unit_progress_string()
logger.info(
f"Facts critic for {progress_info}: visit {state.node_visits[WorkflowNode.CRITICISE_FACTS]}/{state.max_visits_per_node}"
)
llm_tool = await tools.get_llm_tool(state.budget_tracker)
parser = PydanticOutputParser(pydantic_object=FactsCritiqueReport)
ontology_ttl = state.ontology_snapshot.graph.serialize(format="turtle")
ontology_chapter = ontology_template.format(
ontology_ttl=ontology_ttl,
)
facts_ttl = state.content_unit.graph.serialize(format="turtle")
facts_chapter = facts_template.format(
facts_ttl=facts_ttl,
)
text_chapter = text_template.format(text=state.content_unit.text)
user_instruction = (
user_template.format(user_instruction=state.facts_user_instruction)
if state.facts_user_instruction
else ""
)
prompt = PromptTemplate(
template=template_prompt,
input_variables=[
"preamble",
"evaluation_instruction",
"user_instruction",
"ontology_chapter",
"facts_chapter",
"text_chapter",
"format_instructions",
],
)
prompt_data = {
"preamble": preamble,
"evaluation_instruction": evaluation_instruction,
"user_instruction": user_instruction,
"ontology_chapter": ontology_chapter,
"facts_chapter": facts_chapter,
"text_chapter": text_chapter,
"format_instructions": parser.get_format_instructions(),
}
try:
critique: FactsCritiqueReport = await call_llm_with_retry(
llm_tool=llm_tool,
prompt=prompt,
parser=parser,
prompt_kwargs=prompt_data,
)
state.set_external_evidence_request(
WorkflowNode.CRITICISE_FACTS, critique.external_evidence_request
)
logger.debug(
f"Parsed critique report - success: {critique.success}, "
f"score: {critique.score}"
)
if critique.success or critique.score > 90:
state.status = Status.SUCCESS
state.set_node_status(WorkflowNode.CRITICISE_FACTS, Status.SUCCESS)
logger.info("Facts critique passed")
else:
state.status = Status.FAILED
state.set_node_status(WorkflowNode.CRITICISE_FACTS, Status.FAILED)
state.failure_stage = FailureStage.FACTS_CRITIQUE
state.suggestions = Suggestions.from_critique_report(critique)
state.failure_reason = "Facts Critic suggests improvements"
return state
except Exception as e:
logger.error(f"Failed to criticize facts: {str(e)}")
state.set_failure(FailureStage.FACTS_CRITIQUE, str(e))
state.set_node_status(WorkflowNode.CRITICISE_FACTS, Status.FAILED)
return state

View File

@@ -0,0 +1,132 @@
"""Enhanced ontology criticism agent with SPARQL operations.
This module provides enhanced functionality for analyzing and validating ontologies of previous critiques and SPARQL operation support.
"""
import logging
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import PromptTemplate
from ontocast.agent.common import call_llm_with_retry
from ontocast.onto.enum import FailureStage, Status, WorkflowNode
from ontocast.onto.model import OntologyCritiqueReport, Suggestions
from ontocast.onto.unit_states import UnitOntologyState
from ontocast.prompt.common import ontology_template, text_template
from ontocast.prompt.common import system_preamble_ontology as system_preamble
from ontocast.prompt.criticise_ontology import (
intro_instruction,
ontology_criteria,
template_prompt,
)
from ontocast.tool import LLMTool
from ontocast.tool.atomic import AtomicToolBox
logger = logging.getLogger(__name__)
async def criticise_ontology(
state: UnitOntologyState, tools: AtomicToolBox
) -> UnitOntologyState:
"""Enhanced ontology criticism with SPARQL operations.
This function performs a critical analysis of the ontology in the current
state, with SPARQL operation support.
Args:
state: The current unit ontology state containing the ontology to analyze.
tools: The toolbox instance providing utility functions.
Returns:
UnitOntologyState: Updated state with analysis results.
"""
progress_info = state.get_content_unit_progress_string()
logger.info(
f"Ontology Critic for {progress_info}: visit {state.node_visits[WorkflowNode.CRITICISE_ONTOLOGY]}/{state.max_visits_per_node}"
)
if state.content_unit is None:
state.status = Status.FAILED
return state
current = state.current_ontology or state.ontology_snapshot
if current.is_null():
raise ValueError(
f"Null ontology cannot be criticised: {current.iri} is not a valid ontology"
)
parser = PydanticOutputParser(pydantic_object=OntologyCritiqueReport)
llm_tool: LLMTool = await tools.get_llm_tool(state.budget_tracker)
ontology_ttl = current.graph.serialize(format="turtle")
ontology_chapter = ontology_template.format(
ontology_ttl=ontology_ttl,
)
text_chapter = text_template.format(text=state.content_unit.text)
user_instruction = state.ontology_user_instruction
external_evidence = state.external_evidence_text
if external_evidence:
state.mark_external_evidence_used(WorkflowNode.CRITICISE_ONTOLOGY)
prompt = PromptTemplate(
template=template_prompt,
input_variables=[
"preamble",
"intro_instruction",
"ontology_criteria",
"user_instruction",
"ontology_chapter",
"text_chapter",
"external_evidence",
"format_instructions",
],
)
try:
critique: OntologyCritiqueReport = await call_llm_with_retry(
llm_tool=llm_tool,
prompt=prompt,
parser=parser,
prompt_kwargs={
"preamble": system_preamble,
"intro_instruction": intro_instruction,
"ontology_criteria": ontology_criteria,
"text_chapter": text_chapter,
"user_instruction": user_instruction,
"ontology_chapter": ontology_chapter,
"external_evidence": external_evidence,
"format_instructions": parser.get_format_instructions(),
},
)
state.set_external_evidence_request(
WorkflowNode.CRITICISE_ONTOLOGY, critique.external_evidence_request
)
logger.info(
f"Parsed critique report - success: {critique.success}, "
f"score: {critique.score}, n fixes: {len(critique.actionable_ontology_fixes)}."
)
if critique.success or critique.score > 90:
state.status = Status.SUCCESS
state.set_node_status(WorkflowNode.CRITICISE_ONTOLOGY, Status.SUCCESS)
logger.info("Ontology critique passed")
else:
state.status = Status.FAILED
state.failure_stage = FailureStage.ONTOLOGY_CRITIQUE
state.set_node_status(WorkflowNode.CRITICISE_ONTOLOGY, Status.FAILED)
state.suggestions = Suggestions.from_critique_report(critique)
state.failure_reason = "Ontology Critic suggests improvements"
logger.info(
f"Ontology critique failed: {critique.systemic_critique_summary}"
)
return state
except Exception as e:
logger.error(f"Failed to critique ontology: {str(e)}")
state.set_failure(FailureStage.ONTOLOGY_CRITIQUE, str(e))
state.set_node_status(WorkflowNode.CRITICISE_ONTOLOGY, Status.FAILED)
return state

View File

@@ -0,0 +1,448 @@
"""Helpers for optional web-grounded prompts with explicit plan/fetch steps."""
import logging
from typing import TypeVar
from urllib.parse import urlparse
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import PromptTemplate
from ontocast.agent.common import call_llm_with_retry
from ontocast.onto.enum import WorkflowNode
from ontocast.onto.model import (
ExternalEvidenceCacheEntry,
ExternalEvidenceHit,
ExternalEvidencePlan,
ExternalEvidenceRequest,
)
from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState
from ontocast.tool.atomic import AtomicToolBox, SearchHit
logger = logging.getLogger(__name__)
UnitStateT = TypeVar("UnitStateT", UnitFactsState, UnitOntologyState)
_planner_template = """
You are planning optional web-search grounding for a knowledge-graph workflow.
Decide conservatively whether external web evidence is necessary.
Target workflow node:
{target_node}
Source text:
{content_text}
User instruction:
{user_instruction}
Node request rationale:
{search_rationale}
Node query hints:
{query_hints}
Rules:
1. Prefer NOT searching unless there is genuine ambiguity, domain-standard uncertainty,
or term disambiguation need.
2. If searching, propose short, focused queries, not broad summaries of the entire text.
3. Never propose more than {max_queries} queries.
4. If no search is needed, set should_search=false, intent=\"none\", and queries=[].
{format_instructions}
"""
def _get_int(tools: AtomicToolBox, key: str, default: int) -> int:
value = getattr(tools, key, default)
return int(value) if isinstance(value, int | float) else default
def _get_float(tools: AtomicToolBox, key: str, default: float) -> float:
value = getattr(tools, key, default)
return float(value) if isinstance(value, int | float) else default
def _get_bool(tools: AtomicToolBox, key: str, default: bool) -> bool:
value = getattr(tools, key, default)
return bool(value) if isinstance(value, bool) else default
def _get_set(tools: AtomicToolBox, key: str) -> set[str]:
value = getattr(tools, key, set())
if isinstance(value, set):
return {str(entry).strip().lower() for entry in value if str(entry).strip()}
return set()
def _web_grounding_enabled_for_node(
tools: AtomicToolBox, target_node: WorkflowNode
) -> bool:
checker = getattr(tools, "web_grounding_enabled_for_node", None)
if checker is None or not callable(checker):
return False
return bool(checker(target_node))
def build_evidence_query(
content_text: str, user_instruction: str, max_chars: int = 220
) -> str:
"""Backward-compatible fallback query from content and user guidance."""
source = user_instruction.strip() if user_instruction.strip() else content_text
query = " ".join(source.split())
return query[:max_chars].strip()
def _resolve_user_instruction(state: UnitFactsState | UnitOntologyState) -> str:
if isinstance(state, UnitOntologyState):
return state.ontology_user_instruction
return state.facts_user_instruction
def _resolve_content_text(state: UnitFactsState | UnitOntologyState) -> str:
return state.content_unit.text
def _resolve_search_request(
state: UnitFactsState | UnitOntologyState, target_node: WorkflowNode
) -> ExternalEvidenceRequest:
return state.get_external_evidence_request(target_node)
def _normalize_query(query: str) -> str:
return " ".join(query.split()).strip()
def _extract_domain(url: str) -> str:
parsed = urlparse(url)
domain = parsed.netloc.lower()
if domain.startswith("www."):
return domain[4:]
return domain
def _domain_matches(domain: str, patterns: set[str]) -> bool:
for pattern in patterns:
if domain == pattern or domain.endswith(f".{pattern}"):
return True
return False
def sanitize_external_evidence_plan(
plan: ExternalEvidencePlan, tools: AtomicToolBox
) -> ExternalEvidencePlan:
"""Apply deterministic guardrails to planner output."""
deduped_queries: list[str] = []
seen_queries: set[str] = set()
min_chars = max(3, _get_int(tools, "web_search_planner_min_query_chars", 12))
for raw_query in plan.queries:
query = _normalize_query(raw_query)
if len(query) < min_chars:
continue
alpha_chars = sum(1 for char in query if char.isalpha())
if alpha_chars < max(4, min_chars // 2):
continue
lowered = query.lower()
if lowered in seen_queries:
continue
deduped_queries.append(query)
seen_queries.add(lowered)
max_queries = max(1, _get_int(tools, "web_search_planner_max_queries", 3))
min_confidence = _get_float(tools, "web_search_planner_min_confidence", 0.35)
deduped_queries = deduped_queries[:max_queries]
should_search = (
plan.should_search
and plan.intent != "none"
and plan.confidence >= min_confidence
and len(deduped_queries) > 0
)
return ExternalEvidencePlan(
should_search=should_search,
rationale=plan.rationale,
intent=plan.intent if should_search else "none",
confidence=plan.confidence,
queries=deduped_queries if should_search else [],
)
def normalize_search_hits(
hits: list[SearchHit], tools: AtomicToolBox
) -> list[ExternalEvidenceHit]:
"""Filter and normalize search hits with deterministic quality checks."""
normalized_hits: list[ExternalEvidenceHit] = []
seen_urls: set[str] = set()
allowed_domains = _get_set(tools, "web_search_allowed_domains")
blocked_domains = _get_set(tools, "web_search_blocked_domains")
min_snippet_chars = max(0, _get_int(tools, "web_search_min_snippet_chars", 40))
for hit in hits:
url = hit.url.strip()
if not url or url in seen_urls:
continue
domain = _extract_domain(url)
if not domain:
continue
if blocked_domains and _domain_matches(domain, blocked_domains):
continue
if allowed_domains and not _domain_matches(domain, allowed_domains):
continue
snippet = " ".join(hit.snippet.split()).strip()
if len(snippet) < min_snippet_chars:
continue
seen_urls.add(url)
normalized_hits.append(
ExternalEvidenceHit(
title=hit.title.strip() or url,
url=url,
snippet=snippet,
domain=domain,
)
)
return normalized_hits
async def plan_external_evidence_for_node(
state: UnitStateT, tools: AtomicToolBox, target_node: WorkflowNode
) -> UnitStateT:
"""Plan evidence retrieval for a workflow node using LLM + guardrails."""
state.node_visits[WorkflowNode.PLAN_EXTERNAL_EVIDENCE] += 1
if not _web_grounding_enabled_for_node(tools, target_node):
state.set_external_evidence_cache_entry(
target_node, ExternalEvidenceCacheEntry()
)
state.external_evidence_hits = []
state.external_evidence_text = ""
state.external_evidence_source_count = 0
state.external_evidence_domains = []
return state
request = _resolve_search_request(state, target_node)
if not request.initiate_search:
state.set_external_evidence_cache_entry(
target_node, ExternalEvidenceCacheEntry()
)
state.external_evidence_hits = []
state.external_evidence_text = ""
state.external_evidence_source_count = 0
state.external_evidence_domains = []
state.external_evidence_planned_at_node = target_node
return state
cached_entry = state.get_external_evidence_cache_entry(target_node)
if (
_get_bool(tools, "web_search_reuse_evidence_across_attempt", True)
and cached_entry.text
and cached_entry.plan.should_search
):
state.load_external_evidence_for_node(target_node)
return state
user_instruction = _resolve_user_instruction(state)
content_text = _resolve_content_text(state)
if not _get_bool(tools, "web_search_planner_enabled", True):
fallback_query = build_evidence_query(
content_text=content_text, user_instruction=user_instruction
)
fallback_plan = ExternalEvidencePlan(
should_search=bool(fallback_query) or bool(request.query_hints),
rationale="Planner disabled; fallback query from content/instruction.",
intent="background",
confidence=1.0,
queries=[
*([fallback_query] if fallback_query else []),
*request.query_hints,
],
)
sanitized_plan = sanitize_external_evidence_plan(fallback_plan, tools)
state.set_external_evidence_cache_entry(
target_node,
ExternalEvidenceCacheEntry(
plan=sanitized_plan,
hits=[],
text="",
source_count=0,
domains=[],
),
)
state.load_external_evidence_for_node(target_node)
return state
parser = PydanticOutputParser(pydantic_object=ExternalEvidencePlan)
prompt = PromptTemplate(
template=_planner_template,
input_variables=[
"target_node",
"content_text",
"user_instruction",
"max_queries",
"search_rationale",
"query_hints",
"format_instructions",
],
)
llm_tool = await tools.get_llm_tool(state.budget_tracker)
try:
planned: ExternalEvidencePlan = await call_llm_with_retry(
llm_tool=llm_tool,
prompt=prompt,
parser=parser,
prompt_kwargs={
"target_node": target_node.value,
"content_text": content_text,
"user_instruction": user_instruction,
"max_queries": str(
max(1, _get_int(tools, "web_search_planner_max_queries", 3))
),
"search_rationale": request.rationale or "none",
"query_hints": (
"\n".join(f"- {hint}" for hint in request.query_hints)
if request.query_hints
else "none"
),
"format_instructions": parser.get_format_instructions(),
},
)
except Exception as error:
logger.warning(
"Evidence planner failed for %s; skipping external evidence (%s).",
target_node.value,
str(error),
)
planned = ExternalEvidencePlan(
should_search=False,
rationale="Planner failure fallback: skip search.",
intent="none",
confidence=0.0,
queries=[],
)
merged_plan = ExternalEvidencePlan(
should_search=planned.should_search or bool(request.query_hints),
rationale=planned.rationale or request.rationale,
intent=planned.intent,
confidence=max(planned.confidence, request.confidence),
queries=[*planned.queries, *request.query_hints],
)
sanitized_plan = sanitize_external_evidence_plan(merged_plan, tools)
state.set_external_evidence_cache_entry(
target_node,
ExternalEvidenceCacheEntry(
plan=sanitized_plan,
hits=[],
text="",
source_count=0,
domains=[],
),
)
state.load_external_evidence_for_node(target_node)
return state
async def fetch_external_evidence_for_node(
state: UnitStateT, tools: AtomicToolBox, target_node: WorkflowNode
) -> UnitStateT:
"""Fetch and render evidence for a previously planned workflow node."""
state.node_visits[WorkflowNode.FETCH_EXTERNAL_EVIDENCE] += 1
if not _web_grounding_enabled_for_node(tools, target_node):
state.external_evidence_hits = []
state.external_evidence_text = ""
state.external_evidence_source_count = 0
state.external_evidence_domains = []
return state
request = _resolve_search_request(state, target_node)
if not request.initiate_search:
state.set_external_evidence_cache_entry(
target_node, ExternalEvidenceCacheEntry()
)
state.external_evidence_hits = []
state.external_evidence_text = ""
state.external_evidence_source_count = 0
state.external_evidence_domains = []
state.external_evidence_planned_at_node = target_node
return state
cache_entry = state.get_external_evidence_cache_entry(target_node)
plan = cache_entry.plan
if (
_get_bool(tools, "web_search_reuse_evidence_across_attempt", True)
and cache_entry.text
and plan.should_search
):
state.load_external_evidence_for_node(target_node)
return state
if not plan.should_search or not plan.queries:
state.set_external_evidence_cache_entry(
target_node, ExternalEvidenceCacheEntry()
)
state.load_external_evidence_for_node(target_node)
return state
combined_hits: list[SearchHit] = []
for query in plan.queries:
search_hits = await tools.search(query)
combined_hits.extend(search_hits)
normalized_hits = normalize_search_hits(combined_hits, tools)
evidence_text = render_external_evidence(
hits=normalized_hits,
max_snippet_chars=max(40, _get_int(tools, "web_search_max_snippet_chars", 400)),
max_total_chars=max(200, _get_int(tools, "web_search_max_total_chars", 1800)),
)
state.set_external_evidence_cache_entry(
target_node,
ExternalEvidenceCacheEntry(
plan=plan,
hits=normalized_hits,
text=evidence_text,
source_count=len(normalized_hits),
domains=sorted({hit.domain for hit in normalized_hits}),
),
)
state.load_external_evidence_for_node(target_node)
return state
def render_external_evidence(
hits: list[ExternalEvidenceHit],
max_snippet_chars: int,
max_total_chars: int,
) -> str:
"""Render bounded external evidence as a prompt chapter."""
if not hits:
return ""
rendered_lines: list[str] = []
remaining_chars = max_total_chars
for index, hit in enumerate(hits, start=1):
clean_snippet = " ".join(hit.snippet.split())
if len(clean_snippet) > max_snippet_chars:
clean_snippet = f"{clean_snippet[: max_snippet_chars - 3]}..."
line = f"{index}. {hit.title} | {hit.url}\n {clean_snippet}"
if len(line) > remaining_chars:
if remaining_chars < 80:
break
truncated = line[: remaining_chars - 3].rstrip()
line = f"{truncated}..."
rendered_lines.append(line)
break
rendered_lines.append(line)
remaining_chars -= len(line)
if not rendered_lines:
return ""
return (
"### EXTERNAL EVIDENCE (WEB SEARCH)\n"
"Use these sources to clarify uncertain terms or standards only.\n"
"When evidence conflicts, prioritize the source text and ontology context.\n\n"
f"{chr(10).join(rendered_lines)}"
)

View File

@@ -0,0 +1,187 @@
"""Reducers for parallel map/reduce workflow outputs."""
import logging
from rdflib import OWL, RDF, BNode, Node, URIRef
from ontocast.onto.constants import PROV, RDF_REIFIES, SCHEMA
from ontocast.onto.content_unit import ContentUnit
from ontocast.onto.ontology import Ontology
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.sparql_models import GraphUpdate, TripleOp
from ontocast.onto.state import AgentState
from ontocast.toolbox import ToolBox
logger = logging.getLogger(__name__)
def split_ontology_and_provenance_graph(
graph: RDFGraph,
) -> tuple[RDFGraph, RDFGraph]:
"""Split normalized ontology graph into clean ontology + provenance artifact.
Provenance/reification and normalization-time alignment artifacts are moved
to a side graph so downstream consolidation works with a clean ontology graph.
"""
clean_graph = RDFGraph()
provenance_graph = RDFGraph()
for prefix, namespace in graph.namespaces():
if prefix:
clean_graph.bind(prefix, namespace)
provenance_graph.bind(prefix, namespace)
reifier_nodes: set[BNode] = {
subject
for subject, _, _ in graph.triples((None, RDF_REIFIES, None))
if isinstance(subject, BNode)
}
chunk_nodes: set[Node] = set()
def is_schema_chunk_metadata(predicate: Node) -> bool:
predicate_str = str(predicate)
return predicate_str in {
str(SCHEMA.identifier),
str(SCHEMA.position),
"http://schema.org/identifier",
"http://schema.org/position",
}
for subject, predicate, obj in graph:
if is_schema_chunk_metadata(predicate) or predicate == PROV.generatedAtTime:
chunk_nodes.add(subject)
if predicate == RDF.type and str(obj) in {
str(PROV.Entity),
str(SCHEMA.text),
"http://schema.org/text",
}:
chunk_nodes.add(subject)
def is_provenance_or_alignment_triple(
subject: Node, predicate: Node, obj: Node
) -> bool:
if predicate == RDF_REIFIES:
return True
if predicate == PROV.wasDerivedFrom:
# Keep ontology lineage hashes in the clean ontology graph.
if isinstance(obj, URIRef) and str(obj).startswith("urn:hash:"):
return False
return True
if predicate == PROV.generatedAtTime or is_schema_chunk_metadata(predicate):
return True
if predicate == OWL.sameAs:
return True
if subject in reifier_nodes or obj in reifier_nodes:
return True
if subject in chunk_nodes or obj in chunk_nodes:
return True
if predicate == RDF.type and str(obj) in {
str(PROV.Entity),
str(SCHEMA.text),
"http://schema.org/text",
}:
return True
return False
for triple in graph:
if is_provenance_or_alignment_triple(*triple):
provenance_graph.add(triple)
else:
clean_graph.add(triple)
return clean_graph, provenance_graph
def normalize_ontology_units(
units: list[ContentUnit],
tools: ToolBox,
base_ontology: Ontology | None = None,
require_base: bool = False,
) -> tuple[Ontology, list[GraphUpdate], RDFGraph]:
"""Merge ontology unit deltas as TripleOps, then apply to base ontology.
Units contain ontology delta graphs (insert triples only). To preserve the
exact unit output shape (and avoid ontology/facts aggregation rewrites), we
convert each unit graph into an ``insert`` TripleOp and apply them as one
GraphUpdate.
Args:
units: ContentUnits with type=ONTOLOGIES and delta graph from each unit.
tools: ToolBox instance.
base_ontology: Optional ontology to use as base; merged delta is applied to it.
require_base: Whether map/reduce caller expects a base ontology.
Returns:
Tuple of (
ontology with cleaned graph,
list of applied GraphUpdates for versioning,
provenance artifact graph stripped from ontology output,
).
"""
if not units:
if base_ontology is not None:
return base_ontology, [], RDFGraph()
return Ontology(graph=RDFGraph()), [], RDFGraph()
for unit in units:
unit.sanitize()
_ = tools
if require_base and (base_ontology is None or base_ontology.is_null()):
logger.warning(
"normalize_ontology_units expected a base ontology but none was available; "
"continuing with merged aggregated ontology output."
)
merged_update = GraphUpdate(
triple_operations=[
TripleOp(type="insert", graph=unit.graph)
for unit in units
if len(unit.graph) > 0
]
)
if not merged_update.triple_operations:
merged_update = None
if base_ontology is not None and not base_ontology.is_null():
base_graph = base_ontology.graph
if merged_update is not None:
updated_graph, _ = AgentState.render_updated_graph(
base_graph, [merged_update], max_triples=None
)
graph_changed = set(updated_graph) != set(base_graph)
if graph_changed:
result = base_ontology.derive_updated_version(updated_graph)
else:
result = base_ontology.model_copy(deep=True)
result.graph = updated_graph
else:
result = base_ontology.model_copy(deep=True)
result.sync_properties_to_graph()
cleaned_graph, provenance_graph = split_ontology_and_provenance_graph(
result.graph
)
result.graph = cleaned_graph
result.sync_properties_to_graph()
applied = [merged_update] if merged_update else []
return result, applied, provenance_graph
aggregated_delta = RDFGraph()
for unit in units:
for triple in unit.graph:
aggregated_delta.add(triple)
for prefix, namespace in unit.graph.namespaces():
if prefix:
aggregated_delta.bind(prefix, namespace)
cleaned_graph, provenance_graph = split_ontology_and_provenance_graph(
aggregated_delta
)
result = Ontology(
graph=cleaned_graph,
ontology_id=base_ontology.ontology_id if base_ontology else None,
title=base_ontology.title if base_ontology else None,
description=base_ontology.description if base_ontology else None,
)
applied = [merged_update] if merged_update else []
return result, applied, provenance_graph

View File

@@ -0,0 +1,294 @@
"""Fact rendering agent for OntoCast.
This module provides functionality for rendering facts from RDF graphs into
human-readable formats, making the extracted knowledge more accessible and
understandable.
"""
import logging
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import PromptTemplate
from ontocast.agent.common import call_llm_with_retry, render_suggestions_prompt
from ontocast.onto.constants import DEFAULT_IRI
from ontocast.onto.enum import FailureStage, Status, WorkflowNode
from ontocast.onto.model import FactsRenderReport, GraphUpdateRenderReport
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.unit_states import UnitFactsState
from ontocast.prompt.common import (
facts_template,
ontology_template,
output_instruction_empty,
output_instruction_sparql,
text_template,
user_template,
)
from ontocast.prompt.render_facts import (
facts_instruction_template,
preamble,
template_prompt,
)
from ontocast.tool.atomic import AtomicToolBox
logger = logging.getLogger(__name__)
def _extract_known_prefixes(state: UnitFactsState) -> dict[str, str]:
"""Extract ontology prefixes used to patch missing declarations in LLM TTL output."""
known_prefixes: dict[str, str] = {}
if state.ontology_snapshot and state.ontology_snapshot.graph:
for prefix, namespace_uri in state.ontology_snapshot.graph.namespaces():
if prefix: # Skip empty prefixes
known_prefixes[prefix] = str(namespace_uri)
# Also add the ontology prefix explicitly if available.
if state.ontology_snapshot.prefix and state.ontology_snapshot.namespace:
known_prefixes[state.ontology_snapshot.prefix] = (
state.ontology_snapshot.namespace
)
return known_prefixes
async def render_facts(state: UnitFactsState, tools: AtomicToolBox) -> UnitFactsState:
"""Structured hybrid facts renderer with Turtle/SPARQL decision logic.
This function decides between generating bare Turtle for fresh facts
and SPARQL operations for updates based on whether facts exist.
Args:
state: The current unit facts state
tools: The toolbox containing necessary tools
Returns:
UnitFactsState: Updated state with rendered facts
"""
is_fresh_facts_graph = len(state.content_unit.graph) == 0
progress_info = state.get_content_unit_progress_string()
logger.info(f"Render facts for {progress_info}")
if is_fresh_facts_graph:
logger.info("Generating fresh facts as Turtle")
return await render_facts_fresh(state, tools)
else:
logger.info("Generating facts update")
return await render_facts_update(state, tools)
def _prepare_prompt_data(state: UnitFactsState) -> dict[str, str]:
"""Prepare common prompt data for both fresh and update rendering.
Args:
state: The current unit facts state
Returns:
Dictionary containing formatted prompt components
"""
ontology_chapter = ontology_template.format(
ontology_ttl=state.ontology_snapshot.graph.serialize(format="turtle")
)
facts_instruction_str = facts_instruction_template.format(
ontology_namespace=state.ontology_snapshot.namespace,
ontology_prefix=state.ontology_snapshot.prefix,
facts_namespace=DEFAULT_IRI,
)
text_chapter = text_template.format(text=state.content_unit.text)
fact_chapter = ""
user_instruction = (
user_template.format(user_instruction=state.facts_user_instruction)
if state.facts_user_instruction
else ""
)
return {
"ontology_chapter": ontology_chapter,
"user_instruction": user_instruction,
"facts_instruction": facts_instruction_str,
"text_chapter": text_chapter,
"fact_chapter": fact_chapter,
}
def _create_prompt_template() -> PromptTemplate:
"""Create the common prompt template used by both rendering functions.
Returns:
Configured PromptTemplate instance
"""
return PromptTemplate(
template=template_prompt,
input_variables=[
"preamble",
"facts_instruction",
"user_instruction",
"ontology_chapter",
"text_chapter",
"improvement_instruction",
"output_instruction",
"format_instructions",
],
)
def _handle_rendering_error(
state: UnitFactsState, error: Exception, stage: FailureStage
) -> UnitFactsState:
"""Handle rendering errors consistently.
Args:
state: The current agent state
error: The exception that occurred
stage: The failure stage to set
Returns:
Updated state with failure information
"""
logger.error(f"Failed to generate triples: {str(error)}")
state.set_failure(stage, str(error))
state.set_node_status(WorkflowNode.TEXT_TO_FACTS, Status.FAILED)
return state
async def render_facts_fresh(
state: UnitFactsState, tools: AtomicToolBox
) -> UnitFactsState:
"""Render fresh facts from the current chunk into Turtle format.
Args:
state: The current unit facts state containing the chunk to render.
tools: The toolbox instance providing utility functions.
Returns:
UnitFactsState: Updated state with rendered facts.
"""
logger.info("Rendering fresh facts")
llm_tool = await tools.get_llm_tool(state.budget_tracker)
parser = PydanticOutputParser(pydantic_object=FactsRenderReport)
known_prefixes = _extract_known_prefixes(state)
prompt_data = _prepare_prompt_data(state)
prompt_data_fresh = {
"preamble": preamble,
"improvement_instruction": "",
"output_instruction": output_instruction_empty,
}
prompt_data.update(prompt_data_fresh)
prompt = _create_prompt_template()
try:
# Set known prefixes in context before parsing
RDFGraph.set_known_prefixes(known_prefixes if known_prefixes else None)
render_report: FactsRenderReport = await call_llm_with_retry(
llm_tool=llm_tool,
prompt=prompt,
parser=parser,
prompt_kwargs={
"format_instructions": parser.get_format_instructions(),
**prompt_data,
},
)
state.set_external_evidence_request(
WorkflowNode.TEXT_TO_FACTS, render_report.external_evidence_request
)
facts_report = render_report.facts_report
facts_report.semantic_graph.sanitize_prefixes_namespaces()
state.content_unit.graph = facts_report.semantic_graph
# Track triples in budget tracker (fresh facts)
num_triples = len(facts_report.semantic_graph)
logger.info(f"Fresh facts generated with {num_triples} triple(s).")
state.budget_tracker.add_facts_update(num_operations=1, num_triples=num_triples)
state.clear_failure()
state.set_node_status(WorkflowNode.TEXT_TO_FACTS, Status.SUCCESS)
return state
except Exception as e:
return _handle_rendering_error(state, e, FailureStage.GENERATE_TTL_FOR_FACTS)
finally:
# Clear the context after parsing
RDFGraph.set_known_prefixes(None)
async def render_facts_update(
state: UnitFactsState, tools: AtomicToolBox
) -> UnitFactsState:
"""Render facts updates using SPARQL operations.
Args:
state: The current unit facts state containing the chunk to render.
tools: The toolbox instance providing utility functions.
Returns:
UnitFactsState: Updated state with rendered facts.
"""
logger.info("Rendering updates for facts")
llm_tool = await tools.get_llm_tool(state.budget_tracker)
parser = PydanticOutputParser(pydantic_object=GraphUpdateRenderReport)
prompt_data = _prepare_prompt_data(state)
prompt_data_update = {
"preamble": preamble,
"improvement_instruction": render_suggestions_prompt(
state.suggestions, WorkflowNode.TEXT_TO_FACTS
),
"output_instruction": output_instruction_sparql,
"fact_chapter": facts_template.format(
facts_ttl=state.content_unit.graph.serialize(format="turtle")
),
}
prompt_data.update(prompt_data_update)
prompt = _create_prompt_template()
known_prefixes = _extract_known_prefixes(state)
try:
# Set known prefixes in context before parsing
RDFGraph.set_known_prefixes(known_prefixes if known_prefixes else None)
render_report: GraphUpdateRenderReport = await call_llm_with_retry(
llm_tool=llm_tool,
prompt=prompt,
parser=parser,
prompt_kwargs={
"format_instructions": parser.get_format_instructions(),
**prompt_data,
},
)
state.set_external_evidence_request(
WorkflowNode.TEXT_TO_FACTS, render_report.external_evidence_request
)
graph_update = render_report.graph_update
state.facts_updates.append(graph_update)
state.update_facts()
num_operations, num_triples = graph_update.count_total_triples()
logger.info(
f"Facts update has {num_operations} operation(s) "
f"with {num_triples} total triple(s)."
)
# Track triples in budget tracker
state.budget_tracker.add_facts_update(num_operations, num_triples)
state.set_node_status(WorkflowNode.TEXT_TO_FACTS, Status.SUCCESS)
state.clear_failure()
return state
except Exception as e:
return _handle_rendering_error(
state, e, FailureStage.GENERATE_SPARQL_UPDATE_FOR_FACTS
)
finally:
# Clear the context after parsing
RDFGraph.set_known_prefixes(None)

View File

@@ -0,0 +1,289 @@
"""Ontology triple rendering agent for OntoCast.
This module provides functionality for rendering RDF triples from ontologies into
human-readable formats, making the ontological knowledge more accessible and
understandable.
The agent decides between generating bare Turtle for fresh ontologies and SPARQL operations for updates.
"""
import logging
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import PromptTemplate
from ontocast.agent.common import call_llm_with_retry, render_suggestions_prompt
from ontocast.onto.enum import FailureStage, Status, WorkflowNode
from ontocast.onto.model import GraphUpdateRenderReport, OntologyRenderReport
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.unit_states import UnitOntologyState
from ontocast.prompt.common import (
ontology_template,
output_instruction_sparql,
output_instruction_ttl,
text_template,
)
from ontocast.prompt.common import system_preamble_ontology as system_preamble
from ontocast.prompt.render_ontology import (
general_ontology_instruction,
intro_instruction_fresh,
intro_instruction_update,
prefix_instruction,
prefix_instruction_fresh,
template_prompt,
)
from ontocast.tool.atomic import AtomicToolBox
logger = logging.getLogger(__name__)
def _extract_known_prefixes(state: UnitOntologyState) -> dict[str, str]:
"""Extract ontology prefixes used to patch missing declarations in LLM TTL output."""
current = state.current_ontology or state.ontology_snapshot
known_prefixes: dict[str, str] = {}
if current and current.graph:
for prefix, namespace_uri in current.graph.namespaces():
if prefix: # Skip empty prefixes
known_prefixes[prefix] = str(namespace_uri)
if current.prefix and current.namespace:
known_prefixes[current.prefix] = current.namespace
return known_prefixes
async def render_ontology(
state: UnitOntologyState, tools: AtomicToolBox
) -> UnitOntologyState:
"""Structured hybrid ontology renderer with Turtle/SPARQL decision logic.
This function decides between generating bare Turtle for fresh ontologies
and SPARQL operations for updates based on whether the ontology exists.
Args:
state: The current unit ontology state
tools: The toolbox containing necessary tools
Returns:
UnitOntologyState: Updated state with rendered ontology
"""
progress_info = state.get_content_unit_progress_string()
logger.info(
f"Ontology Renderer for {progress_info}: visit {state.node_visits[WorkflowNode.TEXT_TO_ONTOLOGY]}/{state.max_visits_per_node}"
)
current = state.current_ontology or state.ontology_snapshot
# Guardrail for map/reduce flow: if a non-null snapshot exists, stay in update mode.
has_seed_ontology = not state.ontology_snapshot.is_null()
has_no_seed_ontology = current.is_null() and not has_seed_ontology
if has_no_seed_ontology:
return await render_ontology_fresh(state, tools)
else:
return await render_ontology_update(state, tools)
async def render_ontology_fresh(
state: UnitOntologyState, tools: AtomicToolBox
) -> UnitOntologyState:
"""Render ontology triples into a human-readable format.
This function takes the triples from the current ontology and renders them
into a more accessible format, making the ontological knowledge easier to
understand.
Args:
state: The current agent state containing the ontology to render.
tools: The toolbox instance providing utility functions.
Returns:
AgentState: Updated state with rendered triples.
"""
parser = PydanticOutputParser(pydantic_object=OntologyRenderReport)
logger.info("Rendering fresh ontology")
intro_instruction = intro_instruction_fresh.format(
current_domain=state.current_domain
)
output_instruction = output_instruction_ttl
ontology_ttl = ""
improvement_instruction_str = ""
general_ontology_instruction_str = general_ontology_instruction.format(
prefix_instruction=prefix_instruction_fresh
)
text_chapter = text_template.format(text=state.content_unit.text)
external_evidence = state.external_evidence_text
if external_evidence:
state.mark_external_evidence_used(WorkflowNode.TEXT_TO_ONTOLOGY)
prompt = PromptTemplate(
template=template_prompt,
input_variables=[
"preamble",
"intro_instruction",
"ontology_instruction",
"output_instruction",
"user_instruction",
"improvement_instruction",
"ontology_ttl",
"text",
"external_evidence",
"format_instructions",
],
)
try:
llm_tool = await tools.get_llm_tool(state.budget_tracker)
render_report: OntologyRenderReport = await call_llm_with_retry(
llm_tool=llm_tool,
prompt=prompt,
parser=parser,
prompt_kwargs={
"preamble": system_preamble,
"intro_instruction": intro_instruction,
"ontology_instruction": general_ontology_instruction_str,
"output_instruction": output_instruction,
"ontology_ttl": ontology_ttl,
"user_instruction": state.ontology_user_instruction,
"improvement_instruction": improvement_instruction_str,
"text": text_chapter,
"external_evidence": external_evidence,
"format_instructions": parser.get_format_instructions(),
},
)
state.set_external_evidence_request(
WorkflowNode.TEXT_TO_ONTOLOGY, render_report.external_evidence_request
)
state.current_ontology = render_report.ontology
state.current_ontology.graph.sanitize_prefixes_namespaces()
num_triples = len(state.current_ontology.graph)
logger.info(f"New ontology created with {num_triples} triple(s).")
# Track triples in budget tracker (fresh ontology)
state.budget_tracker.add_ontology_update(
num_operations=1, num_triples=num_triples
)
state.clear_failure()
state.set_node_status(WorkflowNode.TEXT_TO_ONTOLOGY, Status.SUCCESS)
return state
except Exception as e:
logger.error(f"Failed to generate triples: {str(e)}")
state.set_node_status(WorkflowNode.TEXT_TO_ONTOLOGY, Status.FAILED)
state.set_failure(FailureStage.GENERATE_TTL_FOR_ONTOLOGY, str(e))
return state
async def render_ontology_update(
state: UnitOntologyState, tools: AtomicToolBox
) -> UnitOntologyState:
"""Render ontology triples into a human-readable format.
This function takes the triples from the current ontology and renders them
into a more accessible format, making the ontological knowledge easier to
understand.
Args:
state: The current unit ontology state containing the ontology to render.
tools: The toolbox instance providing utility functions.
Returns:
UnitOntologyState: Updated state with rendered triples.
"""
parser = PydanticOutputParser(pydantic_object=GraphUpdateRenderReport)
current = state.current_ontology or state.ontology_snapshot
ontology_iri = current.iri
ontology_desc = current.describe()
intro_instruction = intro_instruction_update.format(
ontology_iri=ontology_iri, ontology_desc=ontology_desc
)
ontology_chapter = ontology_template.format(
ontology_ttl=current.graph.serialize(format="turtle")
)
output_instruction = output_instruction_sparql
improvement_instruction_str = render_suggestions_prompt(
state.suggestions, WorkflowNode.TEXT_TO_ONTOLOGY
)
general_ontology_instruction_str = general_ontology_instruction.format(
prefix_instruction=prefix_instruction.format(ontology_prefix=current.prefix),
ontology_prefix=current.prefix,
)
text_chapter = text_template.format(text=state.content_unit.text)
external_evidence = state.external_evidence_text
if external_evidence:
state.mark_external_evidence_used(WorkflowNode.TEXT_TO_ONTOLOGY)
prompt = PromptTemplate(
template=template_prompt,
input_variables=[
"preamble",
"intro_instruction",
"ontology_instruction",
"output_instruction",
"user_instruction",
"improvement_instruction",
"ontology_ttl",
"text",
"external_evidence",
"format_instructions",
],
)
known_prefixes = _extract_known_prefixes(state)
try:
llm_tool = await tools.get_llm_tool(state.budget_tracker)
# Set known prefixes in context before parsing
RDFGraph.set_known_prefixes(known_prefixes if known_prefixes else None)
render_report: GraphUpdateRenderReport = await call_llm_with_retry(
llm_tool=llm_tool,
prompt=prompt,
parser=parser,
prompt_kwargs={
"preamble": system_preamble,
"intro_instruction": intro_instruction,
"ontology_instruction": general_ontology_instruction_str,
"output_instruction": output_instruction,
"improvement_instruction": improvement_instruction_str,
"ontology_ttl": ontology_chapter,
"user_instruction": state.ontology_user_instruction,
"text": text_chapter,
"external_evidence": external_evidence,
"format_instructions": parser.get_format_instructions(),
},
)
state.set_external_evidence_request(
WorkflowNode.TEXT_TO_ONTOLOGY, render_report.external_evidence_request
)
graph_update = render_report.graph_update
state.ontology_updates.append(graph_update)
state.update_ontology()
num_operations, num_triples = graph_update.count_total_triples()
logger.info(
f"Ontology update has {num_operations} operation(s) "
f"with {num_triples} total triple(s)."
)
# Track triples in budget tracker
state.budget_tracker.add_ontology_update(num_operations, num_triples)
state.clear_failure()
state.set_node_status(WorkflowNode.TEXT_TO_ONTOLOGY, Status.SUCCESS)
return state
except Exception as e:
logger.error(f"Failed to generate ontology update: {str(e)}")
state.set_node_status(WorkflowNode.TEXT_TO_ONTOLOGY, Status.FAILED)
state.set_failure(FailureStage.GENERATE_SPARQL_UPDATE_FOR_ONTOLOGY, str(e))
return state
finally:
# Clear the context after parsing
RDFGraph.set_known_prefixes(None)

View File

@@ -0,0 +1,196 @@
"""Ontology selection agent for OntoCast.
This module provides functionality for selecting appropriate ontologies based on
the content of source text segments, ensuring that the chosen ontology best matches the
domain and requirements of the text.
"""
import logging
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import PromptTemplate
from ontocast.agent.common import call_llm_with_retry
from ontocast.onto.enum import Status
from ontocast.onto.model import create_ontology_selector_report_model
from ontocast.onto.null import NULL_ONTOLOGY
from ontocast.onto.state import AgentState
from ontocast.prompt.select_ontology import template_prompt
from ontocast.tool import OntologyManager
from ontocast.toolbox import ToolBox
logger = logging.getLogger(__name__)
def _create_document_excerpt(state: AgentState, max_length: int = 3000) -> str:
"""Create a representative excerpt from the document for ontology selection.
This function samples text from multiple content units to provide a better
representation of the document content than just the first unit.
Args:
state: The current agent state.
max_length: Maximum total length of the excerpt.
Returns:
str: A representative excerpt from the document.
"""
excerpt_parts = []
total_length = 0
chunk_length = max_length // 3 # Aim for ~3 source chunks, ~1000 chars each
# Strategy: Sample from first, middle, and last units if available
if state.content_units:
num_chunks = len(state.content_units)
indices_to_sample = []
if num_chunks == 1:
indices_to_sample = [0]
elif num_chunks == 2:
indices_to_sample = [0, 1]
else:
# Sample first, middle, and last
indices_to_sample = [0, num_chunks // 2, num_chunks - 1]
for idx in indices_to_sample:
if idx < num_chunks and total_length < max_length:
chunk_text = state.content_units[idx].text
# Take a portion of this source chunk
remaining = max_length - total_length
sample_length = min(chunk_length, remaining, len(chunk_text))
if sample_length > 0:
if sample_length < len(chunk_text):
excerpt_parts.append(chunk_text[:sample_length] + " ...")
else:
excerpt_parts.append(chunk_text)
total_length += sample_length
if excerpt_parts:
return "\n\n[...]\n\n".join(excerpt_parts)
# Fallback: Use input_text if available
if state.input_text:
if len(state.input_text) <= max_length:
return state.input_text
return state.input_text[:max_length] + " ..."
# Last resort: Use current content unit
if state.current_content_unit and state.current_content_unit.text:
chunk_text = state.current_content_unit.text
if len(chunk_text) <= max_length:
return chunk_text
return chunk_text[:max_length] + " ..."
return ""
async def select_ontology(state: AgentState, tools: ToolBox) -> AgentState:
"""Select an appropriate ontology for the document.
This function analyzes the document and selects the most appropriate
ontology based on its content and requirements using a numbered list selection.
If an ontology is already selected, it skips selection to ensure one ontology
per document.
Args:
state: The current agent state containing the document to process.
tools: The toolbox instance providing utility functions.
Returns:
AgentState: Updated state with selected ontology.
"""
# Skip if ontology already selected (for subsequent chunks in the loop)
if not state.current_ontology.is_null():
logger.debug(
f"Ontology already selected: {state.current_ontology.ontology_id}, "
"skipping selection to maintain one ontology per document"
)
state.status = Status.SUCCESS
return state
progress_info = state.get_content_unit_progress_string()
logger.info(f"Selecting ontology for document ({progress_info})")
llm_tool = tools.llm
om_tool: OntologyManager = tools.ontology_manager
if om_tool.has_ontologies:
ontologies = om_tool.ontologies
num_ontologies = len(ontologies)
# Create numbered list of ontologies
ontologies_list_lines = []
for i, ontology in enumerate(ontologies, start=1):
ontologies_list_lines.append(f"{i}. {ontology.describe()}")
ontologies_list = "\n\n".join(ontologies_list_lines)
logger.info(f"Presenting {num_ontologies} ontologies for selection")
# Create a better document excerpt using multiple chunks
excerpt = _create_document_excerpt(state, max_length=3000)
# Create dynamic model with correct constraint
ontology_selector_report_model = create_ontology_selector_report_model(
num_ontologies
)
parser = PydanticOutputParser(pydantic_object=ontology_selector_report_model)
prompt = PromptTemplate(
template=template_prompt,
input_variables=[
"excerpt",
"ontologies_list",
"num_ontologies",
"format_instructions",
],
)
selector = await call_llm_with_retry(
llm_tool=llm_tool,
prompt=prompt,
parser=parser,
prompt_kwargs={
"excerpt": excerpt,
"ontologies_list": ontologies_list,
"num_ontologies": num_ontologies,
"format_instructions": parser.get_format_instructions(),
},
)
# Map answer_index to ontology
# answer_index: 0 -> select None
# answer_index: 1 to num_ontologies -> select ontology at (answer_index - 1)
state.status = Status.SUCCESS
if selector.answer_index == 0:
# None selected
logger.debug("LLM selected: None (no suitable ontology)")
state.current_ontology = NULL_ONTOLOGY
elif 1 <= selector.answer_index <= num_ontologies:
# Select ontology at index (answer_index - 1) since list is 0-based
selected_ontology = ontologies[selector.answer_index - 1]
logger.debug(
f"LLM selected ontology at index {selector.answer_index}: "
f"{selected_ontology.ontology_id} ({selected_ontology.iri})"
)
state.current_ontology = selected_ontology
state.status = Status.SUCCESS
else:
# This should not happen due to Pydantic validation, but handle gracefully
logger.warning(
f"Invalid answer_index {selector.answer_index} defaulting to NULL_ONTOLOGY"
)
state.current_ontology = NULL_ONTOLOGY
else:
state.current_ontology = NULL_ONTOLOGY
# Set the initial version if not already set (tracks original version when ontology was selected)
if state.current_ontology.initial_version is None:
state.current_ontology.initial_version = state.current_ontology.version
logger.debug(
f"Set initial version for ontology {state.current_ontology.ontology_id}: {state.current_ontology.initial_version}"
)
logger.debug(f"Current ontology set to: {state.current_ontology.ontology_id}")
return state

View File

@@ -0,0 +1,78 @@
"""Serialization agent for OntoCast.
This module provides functionality for serializing the knowledge graph
(ontology and facts) to the triple store.
"""
import logging
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.state import AgentState
from ontocast.toolbox import ToolBox
logger = logging.getLogger(__name__)
def serialize(state: AgentState, tools: ToolBox) -> AgentState:
"""Serialize the knowledge graph to the triple store.
This function:
- Handles version management for updated ontologies
- Tracks budget usage
- Serializes both ontology and facts to the triple store
Args:
state: Current agent state with ontology and facts
tools: ToolBox containing serialization tools
Returns:
Updated agent state after serialization
"""
# Initialize empty facts graph if not set (for ontology-only render mode)
if state.aggregated_facts is None:
state.aggregated_facts = RDFGraph()
logger.info("No facts to serialize (ontology-only render mode)")
# Ontology versioning: reduce_ontology sets ontology_updates_applied with the
# merged GraphUpdate when aggregating parallel ontology units.
if state.ontology_updates_applied:
logger.info(
f"Ontology was updated during processing ({len(state.ontology_updates_applied)} update operations). "
f"Analyzing changes to determine version increment..."
)
state.current_ontology.mark_as_updated(state.ontology_updates_applied)
state.current_ontology.sync_properties_to_graph()
elif state.ontology_units:
logger.debug("Ontology from EmbeddingBasedAggregator; skipping version bump")
else:
logger.debug(
f"Ontology unchanged during processing (version: {state.current_ontology.version})"
)
# Report LLM budget usage
if state.budget_tracker:
logger.info(state.budget_tracker.get_summary())
provenance_graph_uri = f"{str(state.graph_uri).rstrip('/')}/ontology-provenance"
if len(state.ontology_provenance_artifact) > 0:
logger.info(
"Persisting ontology provenance artifact (%d triples) to graph %s",
len(state.ontology_provenance_artifact),
provenance_graph_uri,
)
if tools.filesystem_manager is not None:
tools.filesystem_manager.serialize(
state.ontology_provenance_artifact,
graph_uri=provenance_graph_uri,
)
if (
tools.triple_store_manager is not None
and tools.triple_store_manager != tools.filesystem_manager
):
tools.triple_store_manager.serialize(
state.ontology_provenance_artifact,
graph_uri=provenance_graph_uri,
)
tools.serialize(state)
return state

View File

@@ -0,0 +1,153 @@
"""Ontology sublimation agent for OntoCast.
This module provides functionality for refining and enhancing ontologies through
a process of sublimation, which involves improving the structure, consistency,
and expressiveness of the ontological knowledge.
"""
import logging
from typing import Iterable, cast
from rdflib.term import Node
from ontocast.onto.constants import DEFAULT_IRI
from ontocast.onto.enum import FailureStage
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.state import AgentState
from ontocast.toolbox import ToolBox
logger = logging.getLogger(__name__)
def _sublimate_ontology(state: AgentState) -> tuple[RDFGraph, RDFGraph]:
graph_onto_addendum = RDFGraph()
graph_facts_pure = RDFGraph()
# Copy all prefixes from the original graph to both new graphs
for prefix, namespace in state.current_content_unit.graph.namespaces():
graph_onto_addendum.bind(prefix, namespace)
graph_facts_pure.bind(prefix, namespace)
query_ontology = f"""
PREFIX cd: <{DEFAULT_IRI}>
SELECT ?s ?p ?o
WHERE {{
?s ?p ?o .
FILTER (
!(
STRSTARTS(STR(?s), STR(cd:)) ||
STRSTARTS(STR(?p), STR(cd:)) ||
(isIRI(?o) && STRSTARTS(STR(?o), STR(cd:)))
)
)
}}
"""
results = cast(
Iterable[tuple[Node, Node, Node]],
state.current_content_unit.graph.query(query_ontology),
)
# Add filtered triples to the new graph
for s, p, o in results:
graph_onto_addendum.add((s, p, o))
query_facts = f"""
PREFIX cd: <{DEFAULT_IRI}>
SELECT ?s ?p ?o
WHERE {{
?s ?p ?o .
FILTER (
STRSTARTS(STR(?s), STR(cd:)) ||
STRSTARTS(STR(?p), STR(cd:)) ||
(isIRI(?o) && STRSTARTS(STR(?o), STR(cd:)))
)
}}
"""
results = cast(
Iterable[tuple[Node, Node, Node]],
state.current_content_unit.graph.query(query_facts),
)
# Add filtered triples to the new graph
for s, p, o in results:
graph_facts_pure.add((s, p, o))
logger.info(
f"Found triples: facts {len(graph_facts_pure)}; ontology {len(graph_onto_addendum)}"
)
return graph_onto_addendum, graph_facts_pure
def sublimate_ontology(state: AgentState, tools: ToolBox):
logger.debug("Starting ontology sublimation")
if state.current_ontology is None:
return state
try:
state.update_facts()
graph_onto_addendum, graph_facts = _sublimate_ontology(state=state)
# Ensure ontology is not null and ontology_id is set before updating
if len(graph_onto_addendum) > 0:
logger.info("ontology seeped into facts:")
logger.info(f"graph: {graph_onto_addendum.serialize()}")
if state.current_ontology.is_null():
logger.warning(
"Cannot update ontology: null ontology cannot be updated"
)
elif state.current_ontology.ontology_id:
# Check if adding triples would exceed max_triples limit
max_triples = state.ontology_max_triples
if max_triples is not None:
current_size = len(state.current_ontology.graph)
addendum_size = len(graph_onto_addendum)
if current_size + addendum_size > max_triples:
logger.warning(
f"Ontology sublimation skipped: would exceed limit "
f"({current_size + addendum_size} > {max_triples} triples). "
f"Current size: {current_size} triples."
)
else:
# Only update state.current_ontology, not OntologyManager
# OntologyManager will be updated in serialize() during final serialization
state.current_ontology.graph += graph_onto_addendum
logger.debug(
f"Updated state.current_ontology with {len(graph_onto_addendum)} triples from sublimation"
)
else:
# No limit set, proceed with update
state.current_ontology.graph += graph_onto_addendum
logger.debug(
f"Updated state.current_ontology with {len(graph_onto_addendum)} triples from sublimation"
)
else:
logger.warning("Cannot update ontology: ontology_id is None")
# Ensure graph_facts is an RDFGraph instance
if not isinstance(graph_facts, RDFGraph):
logger.warning("received an rdflib.Graph rather than RDFGraph")
new_graph = RDFGraph()
graph_facts_rdflib = cast(Iterable[tuple[Node, Node, Node]], graph_facts)
for triple in graph_facts_rdflib:
new_graph.add(triple)
graph_facts_namespaces = cast(
Iterable[tuple[str, str]], graph_facts.namespaces()
)
for prefix, namespace in graph_facts_namespaces:
new_graph.bind(prefix, namespace)
graph_facts = new_graph
state.current_content_unit.graph = graph_facts
state.clear_failure()
except Exception as e:
logger.error(f"Error in sublimate_ontology: {str(e)}")
state.set_failure(
FailureStage.SUBLIMATE_ONTOLOGY,
str(e),
)
return state