This commit is contained in:
LASTA_DEV01\lasta
2026-05-13 19:57:34 +09:00
parent 2e9204243d
commit 9e88f4c7ad
4310 changed files with 48538 additions and 905279 deletions

View File

View File

@@ -0,0 +1,200 @@
"""Regression tests for the OntoCast `convert_document` agent.
Covers the multi-file corpus extension described in
`docs/통합설계서.md` §5 Phase 0 and OntoCast 분석 §13.1 / §21.1.
Original behavior: the loop overwrote `state.input_text` on every iteration,
so multi-file input silently lost all but the last file. The fix accumulates
into one corpus with explicit file-boundary separators while keeping
single-file behavior byte-identical.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
VENDORED_ONTOCAST = REPO_ROOT / "vendored" / "ontocast"
if str(VENDORED_ONTOCAST) not in sys.path:
sys.path.insert(0, str(VENDORED_ONTOCAST))
from ontocast.agent import convert_document as convert_document_module # noqa: E402
from ontocast.onto.enum import Status # noqa: E402
class _StubConverter:
"""Minimal stand-in for `ConverterTool`.
Accepts a fake PDF/DOCX file (any bytes) and returns a dict shaped like
the real converter's output.
"""
supported_extensions = {".pdf", ".docx"}
def __init__(self, mapping: dict[bytes, str]) -> None:
self._mapping = mapping
def __call__(self, file_content: bytes) -> dict[str, str]:
return {"text": self._mapping[file_content]}
def _make_state(files: dict[str, bytes]) -> SimpleNamespace:
"""Build a lightweight stand-in for `AgentState`.
We intentionally avoid constructing the real Pydantic model here — its
initialization touches many unrelated fields and tools. We only mirror
the attributes that `convert_document` reads or writes.
"""
captured_text: list[str] = []
def set_text(text: str) -> None:
captured_text.append(text)
state = SimpleNamespace(
files=files,
status=None,
input_text="",
ontology_user_instruction="",
facts_user_instruction="",
source_url=None,
set_text=set_text,
_captured_text=captured_text,
)
return state
def _make_tools(converter_mapping: dict[bytes, str]) -> SimpleNamespace:
return SimpleNamespace(converter=_StubConverter(converter_mapping))
# ─── Case 1: single PDF — output must equal the file's text verbatim ─────
def test_single_pdf_passes_through_unchanged() -> None:
pdf_bytes = b"%PDF-1.4 fake"
state = _make_state({"sample.pdf": pdf_bytes})
tools = _make_tools({pdf_bytes: "Hello from PDF."})
result = convert_document_module.convert_document(state, tools)
assert result.status == Status.SUCCESS
assert state._captured_text == ["Hello from PDF."]
# ─── Case 2: single JSON — text + corpus metadata flows through ──────────
def test_single_json_extracts_metadata_and_text() -> None:
payload = {
"text": "Body of the article.",
"url": "https://example.com/a",
"ontology_user_instruction": "Focus on organizations.",
"facts_user_instruction": "Extract person-org links.",
}
state = _make_state({"a.json": json.dumps(payload).encode("utf-8")})
tools = _make_tools({})
result = convert_document_module.convert_document(state, tools)
assert result.status == Status.SUCCESS
assert state._captured_text == ["Body of the article."]
assert state.source_url == "https://example.com/a"
assert state.ontology_user_instruction == "Focus on organizations."
assert state.facts_user_instruction == "Extract person-org links."
# ─── Case 3: multiple PDFs — both bodies survive with boundary marker ────
def test_multiple_pdfs_are_concatenated_with_boundary() -> None:
"""The legacy bug: only the last file's text survived. After the fix,
both texts appear in the corpus separated by `=== File: <name> ===`."""
pdf_a = b"%PDF-1.4 A"
pdf_b = b"%PDF-1.4 B"
state = _make_state({"a.pdf": pdf_a, "b.pdf": pdf_b})
tools = _make_tools({pdf_a: "Text A.", pdf_b: "Text B."})
convert_document_module.convert_document(state, tools)
corpus = state._captured_text[-1]
assert "Text A." in corpus
assert "Text B." in corpus
assert "=== File: a.pdf ===" in corpus
assert "=== File: b.pdf ===" in corpus
# Ordering: a.pdf before b.pdf (insertion order preserved)
assert corpus.index("Text A.") < corpus.index("Text B.")
# ─── Case 4: multiple JSONs — first-wins for corpus metadata ─────────────
def test_multiple_jsons_keep_first_metadata() -> None:
"""`ontology_user_instruction`, `facts_user_instruction`, `source_url`
are corpus-level singletons. The first JSON file that provides each
wins; later JSONs do not overwrite."""
payload_a = {
"text": "Body A.",
"url": "https://first.example/a",
"ontology_user_instruction": "First instruction.",
"facts_user_instruction": "First facts.",
}
payload_b = {
"text": "Body B.",
"url": "https://second.example/b",
"ontology_user_instruction": "Second instruction (must be ignored).",
"facts_user_instruction": "Second facts (must be ignored).",
}
state = _make_state(
{
"a.json": json.dumps(payload_a).encode("utf-8"),
"b.json": json.dumps(payload_b).encode("utf-8"),
}
)
tools = _make_tools({})
convert_document_module.convert_document(state, tools)
assert state.source_url == "https://first.example/a"
assert state.ontology_user_instruction == "First instruction."
assert state.facts_user_instruction == "First facts."
corpus = state._captured_text[-1]
assert "Body A." in corpus and "Body B." in corpus
# ─── Case 5: unsupported extension fails fast ────────────────────────────
def test_unsupported_extension_returns_failed() -> None:
state = _make_state({"weird.xyz": b"???"})
tools = _make_tools({})
result = convert_document_module.convert_document(state, tools)
assert result.status == Status.FAILED
# ─── Case 6: empty files dict is a no-op success ─────────────────────────
def test_empty_files_is_noop_success() -> None:
state = _make_state({})
tools = _make_tools({})
result = convert_document_module.convert_document(state, tools)
assert result.status == Status.SUCCESS
assert state._captured_text == [] # set_text never called
# ─── Case 7: mixed PDF + JSON in one corpus ──────────────────────────────
def test_mixed_pdf_and_json_combine_with_boundaries() -> None:
pdf_bytes = b"%PDF-1.4 mix"
json_payload = {"text": "JSON body."}
state = _make_state(
{
"a.pdf": pdf_bytes,
"b.json": json.dumps(json_payload).encode("utf-8"),
}
)
tools = _make_tools({pdf_bytes: "PDF body."})
convert_document_module.convert_document(state, tools)
corpus = state._captured_text[-1]
assert "=== File: a.pdf ===" in corpus
assert "=== File: b.json ===" in corpus
assert "PDF body." in corpus
assert "JSON body." in corpus

View File

@@ -0,0 +1,120 @@
"""Tests for `platform.config`.
Covers:
- Phase 0 forces filesystem backend even when NEO4J_*/FUSEKI_* are set
- `build_ontocast_config` clears Neo4j/Fuseki on filesystem backend
- Phase < 4 with non-filesystem storage_backend raises eagerly
- working_directory is auto-created
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
PLATFORM_ROOT = REPO_ROOT
if str(PLATFORM_ROOT) not in sys.path:
sys.path.insert(0, str(PLATFORM_ROOT))
# Importing `platform.config` here works because the `platform/` package
# directory has an `__init__.py`. (Avoid the stdlib module of the same name
# by relying on the package being on sys.path before site-packages.)
import importlib
platform_config = importlib.import_module("platform.config")
def _clear_settings_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Wipe all env vars that PlatformSettings or OntoCast Config might read."""
for var in [
"PHASE",
"STORAGE_BACKEND",
"ONTOCAST_WORKING_DIRECTORY",
"ONTOCAST_ONTOLOGY_DIRECTORY",
"HOST",
"PORT",
"LOG_LEVEL",
"ROBOTS_POLICY",
"NEO4J_URI",
"NEO4J_AUTH",
"FUSEKI_URI",
"FUSEKI_AUTH",
"LLM_PROVIDER",
"LLM_API_KEY",
"LLM_MODEL_NAME",
]:
monkeypatch.delenv(var, raising=False)
def test_default_phase_is_base_with_filesystem(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_clear_settings_env(monkeypatch)
monkeypatch.setenv("ONTOCAST_WORKING_DIRECTORY", str(tmp_path / "work"))
settings = platform_config.PlatformSettings() # type: ignore[call-arg]
assert settings.phase == platform_config.Phase.BASE
assert settings.storage_backend == "filesystem"
assert (tmp_path / "work").exists(), "working_directory should be created"
def test_phase0_rejects_neo4j_backend(monkeypatch: pytest.MonkeyPatch) -> None:
_clear_settings_env(monkeypatch)
monkeypatch.setenv("PHASE", "0")
monkeypatch.setenv("STORAGE_BACKEND", "neo4j")
with pytest.raises(ValueError, match="requires Phase 4"):
platform_config.PlatformSettings() # type: ignore[call-arg]
def test_phase0_rejects_fuseki_backend(monkeypatch: pytest.MonkeyPatch) -> None:
_clear_settings_env(monkeypatch)
monkeypatch.setenv("PHASE", "0")
monkeypatch.setenv("STORAGE_BACKEND", "fuseki")
with pytest.raises(ValueError, match="requires Phase 4"):
platform_config.PlatformSettings() # type: ignore[call-arg]
def test_build_ontocast_config_clears_neo4j_and_fuseki(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Even if NEO4J_*/FUSEKI_* env vars are set, Phase 0 must hand
OntoCast a config with both backends disabled."""
_clear_settings_env(monkeypatch)
monkeypatch.setenv("ONTOCAST_WORKING_DIRECTORY", str(tmp_path / "work"))
# Set fake credentials that would otherwise enable both backends.
monkeypatch.setenv("NEO4J_URI", "bolt://localhost:7687")
monkeypatch.setenv("NEO4J_AUTH", "neo4j/changeme")
monkeypatch.setenv("FUSEKI_URI", "http://localhost:3030")
monkeypatch.setenv("FUSEKI_AUTH", "admin:changeme")
# LLM must be valid enough for validate_llm_config to pass.
monkeypatch.setenv("LLM_PROVIDER", "openai")
monkeypatch.setenv("LLM_API_KEY", "test-key")
settings = platform_config.PlatformSettings() # type: ignore[call-arg]
cfg = platform_config.build_ontocast_config(settings)
assert cfg.tool_config.neo4j.uri is None
assert cfg.tool_config.neo4j.auth is None
assert cfg.tool_config.fuseki.uri is None
assert cfg.tool_config.fuseki.auth is None
# Paths must be the platform-overridden value.
assert cfg.tool_config.path_config.working_directory == (tmp_path / "work")
def test_build_ontocast_config_validates_llm_eagerly(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_clear_settings_env(monkeypatch)
monkeypatch.setenv("ONTOCAST_WORKING_DIRECTORY", str(tmp_path / "work"))
monkeypatch.setenv("LLM_PROVIDER", "openai")
# Intentionally omit LLM_API_KEY so validate_llm_config raises.
settings = platform_config.PlatformSettings() # type: ignore[call-arg]
with pytest.raises(ValueError, match="LLM_API_KEY"):
platform_config.build_ontocast_config(settings)

View File

@@ -0,0 +1,178 @@
"""Regression tests for the OntoCast `select_ontology` agent.
Covers the None-selection index fix described in
`docs/통합설계서.md` §5 Phase 0 and OntoCast 분석 §13.1.
The original code checked ``answer_index == 0`` for "None", but the Pydantic
dynamic model constrains ``answer_index`` to ``[1, num_ontologies + 1]``.
The fix maps ``num_ontologies + 1`` to ``NULL_ONTOLOGY`` and removes the
unreachable ``answer_index == 0`` branch.
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
# Make the vendored OntoCast importable. The repo layout is:
# ontology_platform/
# vendored/ontocast/ontocast/...
# We point at `vendored/ontocast` so `import ontocast.<x>` resolves.
REPO_ROOT = Path(__file__).resolve().parents[2]
VENDORED_ONTOCAST = REPO_ROOT / "vendored" / "ontocast"
if str(VENDORED_ONTOCAST) not in sys.path:
sys.path.insert(0, str(VENDORED_ONTOCAST))
# Imports must come after sys.path tweak.
from ontocast.agent import select_ontology as select_ontology_module # noqa: E402
from ontocast.onto.enum import Status # noqa: E402
from ontocast.onto.null import NULL_ONTOLOGY # noqa: E402
def _make_state(num_content_units: int = 1) -> SimpleNamespace:
"""Build a minimal stand-in for `AgentState` used by `select_ontology`.
Only attributes accessed inside the function are populated. Using
`SimpleNamespace` avoids constructing the full Pydantic model and all
its nested defaults, which would couple the test to OntoCast internals
far beyond the scope of this regression.
"""
units = [SimpleNamespace(text=f"chunk {i} text") for i in range(num_content_units)]
return SimpleNamespace(
current_ontology=NULL_ONTOLOGY,
content_units=units,
current_content_unit=units[0] if units else None,
input_text="",
status=None,
# `get_content_unit_progress_string` is only used for logging
get_content_unit_progress_string=lambda: f"{num_content_units} units",
)
def _make_tools(ontologies: list) -> SimpleNamespace:
om = MagicMock()
om.has_ontologies = bool(ontologies)
om.ontologies = ontologies
return SimpleNamespace(llm=MagicMock(), ontology_manager=om)
def _patch_llm_call(monkeypatch: pytest.MonkeyPatch, answer_index: int) -> None:
"""Patch `call_llm_with_retry` to return a stub selector with the given index."""
selector_stub = SimpleNamespace(answer_index=answer_index)
monkeypatch.setattr(
select_ontology_module,
"call_llm_with_retry",
AsyncMock(return_value=selector_stub),
)
# ─── Case 1: no ontologies available ──────────────────────────────────────
@pytest.mark.asyncio
async def test_no_ontologies_returns_null(monkeypatch: pytest.MonkeyPatch) -> None:
"""When no ontologies are registered, the LLM is not called and the
current ontology stays NULL."""
state = _make_state()
tools = _make_tools(ontologies=[])
# Guard: ensure LLM is never invoked when there is nothing to pick from.
sentinel = AsyncMock()
monkeypatch.setattr(select_ontology_module, "call_llm_with_retry", sentinel)
result = await select_ontology_module.select_ontology(state, tools)
assert result.current_ontology is NULL_ONTOLOGY
sentinel.assert_not_called()
# ─── Case 2: LLM picks a valid index ──────────────────────────────────────
@pytest.mark.asyncio
async def test_llm_picks_valid_index(monkeypatch: pytest.MonkeyPatch) -> None:
"""When the LLM returns an index within [1, num_ontologies], the
corresponding ontology is selected (0-based after subtraction)."""
onto_a = MagicMock(name="onto_a", ontology_id="onto-a", iri="urn:a")
onto_a.is_null = MagicMock(return_value=False)
onto_a.initial_version = None
onto_a.version = "1.0"
onto_a.describe = MagicMock(return_value="ontology A description")
onto_b = MagicMock(name="onto_b", ontology_id="onto-b", iri="urn:b")
onto_b.is_null = MagicMock(return_value=False)
onto_b.initial_version = None
onto_b.version = "1.0"
onto_b.describe = MagicMock(return_value="ontology B description")
state = _make_state()
tools = _make_tools(ontologies=[onto_a, onto_b])
# LLM picks the second ontology (1-based index 2)
_patch_llm_call(monkeypatch, answer_index=2)
result = await select_ontology_module.select_ontology(state, tools)
assert result.current_ontology is onto_b
assert result.status == Status.SUCCESS
# ─── Case 3: LLM picks None (num_ontologies + 1) — the bug fix case ──────
@pytest.mark.asyncio
async def test_llm_picks_none_index_returns_null_without_warning(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""When the LLM returns `num_ontologies + 1` (the encoded "None" choice),
the ontology must be set to NULL_ONTOLOGY **and** no WARNING should be
emitted. The pre-fix code logged a warning on every legitimate None
selection because the path fell through to the defensive branch.
"""
onto_a = MagicMock(ontology_id="onto-a", iri="urn:a")
onto_a.is_null = MagicMock(return_value=False)
onto_a.initial_version = None
onto_a.version = "1.0"
onto_a.describe = MagicMock(return_value="ontology A description")
state = _make_state()
tools = _make_tools(ontologies=[onto_a])
# num_ontologies = 1, so None is encoded as 2.
_patch_llm_call(monkeypatch, answer_index=2)
with caplog.at_level(logging.WARNING, logger="ontocast.agent.select_ontology"):
result = await select_ontology_module.select_ontology(state, tools)
assert result.current_ontology is NULL_ONTOLOGY
assert result.status == Status.SUCCESS
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert warnings == [], f"Unexpected warnings: {[r.message for r in warnings]}"
# ─── Case 4: defensive fallback for out-of-range index ───────────────────
@pytest.mark.asyncio
async def test_out_of_range_index_falls_back_to_null(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""If the LLM somehow returns a value outside [1, num_ontologies + 1]
(Pydantic should prevent this, but be defensive), the function must
log a WARNING and default to NULL_ONTOLOGY."""
onto_a = MagicMock(ontology_id="onto-a", iri="urn:a")
onto_a.is_null = MagicMock(return_value=False)
onto_a.initial_version = None
onto_a.version = "1.0"
onto_a.describe = MagicMock(return_value="ontology A description")
state = _make_state()
tools = _make_tools(ontologies=[onto_a])
# Out of range: num_ontologies = 1, valid is {1, 2}; we pass 99.
_patch_llm_call(monkeypatch, answer_index=99)
with caplog.at_level(logging.WARNING, logger="ontocast.agent.select_ontology"):
result = await select_ontology_module.select_ontology(state, tools)
assert result.current_ontology is NULL_ONTOLOGY
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert len(warnings) == 1
assert "Out-of-range answer_index" in warnings[0].message