Фиксирую рабочее состояние
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.steps.step2_load_source_content.step import (
|
||||
LoadSourceContentStep,
|
||||
)
|
||||
from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.steps.step4_load_rules.step import (
|
||||
LoadRulesStep,
|
||||
)
|
||||
from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.steps.step3_parse_requirements.parser import (
|
||||
FunctionalRequirementsParser,
|
||||
)
|
||||
@@ -15,9 +23,15 @@ from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.steps.step
|
||||
from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.subprocesses.common.source_sections import (
|
||||
RequirementSourceSections,
|
||||
)
|
||||
from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.subprocesses.common.rules_catalog import (
|
||||
RulesCatalog,
|
||||
)
|
||||
from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.subprocesses.common.template_parser import (
|
||||
TemplateParser,
|
||||
)
|
||||
from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.subprocesses.common.template_registry import (
|
||||
TemplateRegistry,
|
||||
)
|
||||
from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.workflow_runtime.context import (
|
||||
DocUpdateFromFeatureV2Context,
|
||||
)
|
||||
@@ -178,6 +192,70 @@ def test_task_builder_uses_create_when_path_is_new_and_no_delete_markers() -> No
|
||||
assert tasks[0].path == "docs/orders/web/ui_page/orders.ui.list.md"
|
||||
|
||||
|
||||
def test_task_builder_normalizes_data_entity_to_db_table() -> None:
|
||||
context = DocUpdateFromFeatureV2Context(
|
||||
runtime=SimpleNamespace(),
|
||||
route=SimpleNamespace(),
|
||||
rag_session_id="",
|
||||
analytics_meta=AnalyticsMeta(
|
||||
application="orders_pprb",
|
||||
domain="orders",
|
||||
subdomain="lifecycle",
|
||||
),
|
||||
requirements=[
|
||||
RequirementUnit(
|
||||
section_key="6.5",
|
||||
heading="Создание таблицы orders в БД",
|
||||
body="Описание таблицы orders.",
|
||||
metadata={
|
||||
"id": "orders.db.table.orders",
|
||||
"doc_type": "data_entity",
|
||||
"application": "orders_pprb",
|
||||
"platform": "pprb",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
tasks = RequirementTaskBuilder(_UnexpectedLlmCall()).build(context)
|
||||
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0].doc_type == "db_table"
|
||||
assert tasks[0].path == "docs/orders/pprb/db_table/orders.db.table.orders.md"
|
||||
|
||||
|
||||
def test_task_builder_normalizes_domain_entity_to_db_table() -> None:
|
||||
context = DocUpdateFromFeatureV2Context(
|
||||
runtime=SimpleNamespace(),
|
||||
route=SimpleNamespace(),
|
||||
rag_session_id="",
|
||||
analytics_meta=AnalyticsMeta(
|
||||
application="orders_pprb",
|
||||
domain="orders",
|
||||
subdomain="lifecycle",
|
||||
),
|
||||
requirements=[
|
||||
RequirementUnit(
|
||||
section_key="6.5",
|
||||
heading="Создание таблицы orders в БД",
|
||||
body="Описание таблицы orders.",
|
||||
metadata={
|
||||
"id": "orders.db.table.orders",
|
||||
"doc_type": "domain_entity",
|
||||
"application": "orders_pprb",
|
||||
"platform": "pprb",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
tasks = RequirementTaskBuilder(_UnexpectedLlmCall()).build(context)
|
||||
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0].doc_type == "db_table"
|
||||
assert tasks[0].path == "docs/orders/pprb/db_table/orders.db.table.orders.md"
|
||||
|
||||
|
||||
def test_delete_heuristic_does_not_match_phrase_ne_udalos() -> None:
|
||||
heuristic = DeleteIntentHeuristic()
|
||||
|
||||
@@ -185,6 +263,28 @@ def test_delete_heuristic_does_not_match_phrase_ne_udalos() -> None:
|
||||
assert heuristic.is_delete("Нужно удалить существующую страницу документации.") is True
|
||||
|
||||
|
||||
def test_load_source_content_step_uses_repo_root_when_docs_dir_is_in_parent(tmp_path) -> None:
|
||||
project_root = tmp_path / "test_doc"
|
||||
features_dir = project_root / "features"
|
||||
docs_dir = project_root / "docs"
|
||||
features_dir.mkdir(parents=True)
|
||||
docs_dir.mkdir(parents=True)
|
||||
source_path = features_dir / "order_list.md"
|
||||
source_path.write_text("# Feature", encoding="utf-8")
|
||||
context = DocUpdateFromFeatureV2Context(
|
||||
runtime=SimpleNamespace(),
|
||||
route=SimpleNamespace(),
|
||||
rag_session_id="",
|
||||
source_ref=source_path.as_posix(),
|
||||
source_kind="markdown_file",
|
||||
)
|
||||
|
||||
result = asyncio.run(LoadSourceContentStep().run(context))
|
||||
|
||||
assert result.project_root == project_root.as_posix()
|
||||
assert result.source_content == "# Feature"
|
||||
|
||||
|
||||
def test_template_parser_extracts_ordered_sections_from_ui_template() -> None:
|
||||
parser = TemplateParser()
|
||||
template = """
|
||||
@@ -216,6 +316,43 @@ doc_type: ui_page
|
||||
assert spec.sections[1].has_children is True
|
||||
|
||||
|
||||
def test_template_registry_loads_db_table_template_by_data_entity_alias() -> None:
|
||||
registry = TemplateRegistry()
|
||||
catalog = RulesCatalog(
|
||||
by_name={
|
||||
"templates/db_table.template.md": """
|
||||
---
|
||||
doc_type: db_table
|
||||
---
|
||||
|
||||
# <title>
|
||||
|
||||
## Summary
|
||||
|
||||
## Details
|
||||
""".strip()
|
||||
}
|
||||
)
|
||||
|
||||
spec = registry.load(catalog, "data_entity")
|
||||
|
||||
assert spec.doc_type == "db_table"
|
||||
|
||||
|
||||
def test_load_rules_step_includes_bundled_db_table_template() -> None:
|
||||
step = LoadRulesStep(rules_root=Path("_process/doc_rules_v3"))
|
||||
context = DocUpdateFromFeatureV2Context(
|
||||
runtime=SimpleNamespace(),
|
||||
route=SimpleNamespace(),
|
||||
rag_session_id="",
|
||||
)
|
||||
|
||||
result = asyncio.run(step.run(context))
|
||||
names = {item.name for item in result.rules}
|
||||
|
||||
assert "templates/db_table.template.md" in names
|
||||
|
||||
|
||||
def test_requirement_source_sections_match_template_titles() -> None:
|
||||
locator = RequirementSourceSections()
|
||||
body = """
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
|
||||
from app.core.agent.processes.v2 import V2IntentRouter
|
||||
from app.core.agent.processes.v2.intent_router.modules.target_terms import V2TargetTermsExtractor
|
||||
|
||||
|
||||
class FakeLlm:
|
||||
@@ -131,3 +132,26 @@ def test_router_keeps_short_api_like_token_as_strong_hint_without_explicit_path(
|
||||
assert result.anchors.endpoint_paths == []
|
||||
assert "health endpoint" in result.anchors.target_doc_hints
|
||||
assert "health" in result.target_terms
|
||||
|
||||
|
||||
def test_router_routes_explicit_feature_doc_build_to_doc_update_without_llm() -> None:
|
||||
llm = FakeLlm(_llm_response("DOCS", "DOC_EXPLAIN", "FIND_FILES"))
|
||||
|
||||
result = V2IntentRouter(llm=llm).route(
|
||||
"Собери документацию по /Users/alex/Dev_projects_v2/ai driven app process/v2/test_doc/features/order_list.md"
|
||||
)
|
||||
|
||||
assert result.intent == "DOC_UPDATE"
|
||||
assert result.subintent == "FROM_FEATURE"
|
||||
assert result.routing_mode == "deterministic"
|
||||
assert result.llm_router_used is False
|
||||
assert result.anchors.endpoint_paths == []
|
||||
assert len(llm.calls) == 0
|
||||
|
||||
|
||||
def test_target_terms_extractor_does_not_treat_absolute_filesystem_path_as_endpoint() -> None:
|
||||
analysis = V2TargetTermsExtractor().extract(
|
||||
"Собери документацию по /Users/alex/Dev_projects_v2/ai driven app process/v2/test_doc/features/order_list.md"
|
||||
)
|
||||
|
||||
assert analysis.endpoint_paths == []
|
||||
|
||||
@@ -15,7 +15,20 @@ from app.schemas.orchestration import RequestExecutionStatus
|
||||
|
||||
|
||||
class FakePublisher:
|
||||
def __init__(self) -> None:
|
||||
self.status_events: list[dict[str, object]] = []
|
||||
|
||||
async def publish_status(self, *_args, **_kwargs) -> None:
|
||||
request_id, source, text = _args[:3]
|
||||
payload = _args[3] if len(_args) > 3 else _kwargs.get("payload")
|
||||
self.status_events.append(
|
||||
{
|
||||
"request_id": request_id,
|
||||
"source": source,
|
||||
"text": text,
|
||||
"payload": payload or {},
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
async def publish_user(self, *_args, **_kwargs) -> None:
|
||||
@@ -242,6 +255,25 @@ def test_v2_process_logs_pipeline_steps() -> None:
|
||||
assert "answer_generated" in pipeline_titles
|
||||
|
||||
|
||||
def test_v2_process_publishes_router_status_message() -> None:
|
||||
llm = FakeLlm("Краткое объяснение по документации.")
|
||||
adapter = FakeRagAdapter(summary_rows=_SUMMARY_ROWS, file_rows=[])
|
||||
process = _v2_process(llm, adapter)
|
||||
runtime = _context("Что делает endpoint /health?")
|
||||
|
||||
asyncio.run(process.run(runtime))
|
||||
|
||||
assert runtime.publisher.status_events
|
||||
router_event = runtime.publisher.status_events[0]
|
||||
assert router_event["source"] == "process.v2"
|
||||
assert router_event["text"] == "Запрос принял, переход в объяснение документации."
|
||||
assert router_event["payload"] == {
|
||||
"routing_domain": "DOCS",
|
||||
"intent": "DOC_EXPLAIN",
|
||||
"subintent": "SUMMARY",
|
||||
}
|
||||
|
||||
|
||||
def test_v2_process_blocks_generic_docs_answer_without_target_doc() -> None:
|
||||
llm = FakeLlm("галлюцинация")
|
||||
adapter = FakeRagAdapter(
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
|
||||
from app.core.agent.utils.workflow.graph import WorkflowGraph
|
||||
from app.core.agent.utils.workflow.step import WorkflowStep
|
||||
from app.core.api.domain.models.agent_request import AgentRequest
|
||||
from app.core.api.domain.models.agent_session import AgentSession
|
||||
from app.schemas.orchestration import RequestExecutionStatus
|
||||
|
||||
|
||||
class FakePublisher:
|
||||
def __init__(self) -> None:
|
||||
self.status_events: list[dict[str, object]] = []
|
||||
|
||||
async def publish_status(self, request_id: str, source: str, text: str, payload: dict | None = None) -> None:
|
||||
self.status_events.append(
|
||||
{"request_id": request_id, "source": source, "text": text, "payload": payload or {}}
|
||||
)
|
||||
|
||||
async def publish_user(self, *_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class FakeTrace:
|
||||
def module(self, _name: str) -> "FakeTrace":
|
||||
return self
|
||||
|
||||
def log(self, *_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FakeWorkflowContext:
|
||||
runtime: RuntimeExecutionContext
|
||||
value: int = 0
|
||||
|
||||
|
||||
class HookedStep(WorkflowStep[FakeWorkflowContext]):
|
||||
step_id = "hooked"
|
||||
title = "Hooked"
|
||||
|
||||
async def run(self, context: FakeWorkflowContext) -> FakeWorkflowContext:
|
||||
return FakeWorkflowContext(runtime=context.runtime, value=context.value + 1)
|
||||
|
||||
def get_before_status_message(self) -> str | None:
|
||||
assert self.context is not None
|
||||
assert self.input_context is not None
|
||||
assert self.output_context is None
|
||||
return f"before:{self.context.value}"
|
||||
|
||||
def get_after_status_message(self) -> str | None:
|
||||
assert self.context is not None
|
||||
assert self.input_context is not None
|
||||
assert self.output_context is not None
|
||||
return f"after:{self.input_context.value}->{self.output_context.value}"
|
||||
|
||||
|
||||
def _runtime() -> RuntimeExecutionContext:
|
||||
request = AgentRequest(
|
||||
request_id="req-1",
|
||||
session_id="sess-1",
|
||||
message="x",
|
||||
process_version="v2",
|
||||
status=RequestExecutionStatus.RUNNING,
|
||||
created_at=AgentRequest.create("req-x", "sess-x", "x", "v2").created_at,
|
||||
)
|
||||
session = AgentSession.create("sess-1", "rag-1")
|
||||
return RuntimeExecutionContext(
|
||||
request=request,
|
||||
session=session,
|
||||
publisher=FakePublisher(),
|
||||
trace=FakeTrace(),
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_graph_publishes_before_and_after_status_messages() -> None:
|
||||
context = FakeWorkflowContext(runtime=_runtime(), value=2)
|
||||
graph = WorkflowGraph("wf-1", "workflow.test", [HookedStep()])
|
||||
|
||||
result = asyncio.run(graph.run(context))
|
||||
|
||||
assert result.value == 3
|
||||
assert context.runtime.publisher.status_events == [
|
||||
{
|
||||
"request_id": "req-1",
|
||||
"source": "workflow.test",
|
||||
"text": "before:2",
|
||||
"payload": {"workflow_id": "wf-1", "step_id": "hooked", "phase": "before"},
|
||||
},
|
||||
{
|
||||
"request_id": "req-1",
|
||||
"source": "workflow.test",
|
||||
"text": "after:2->3",
|
||||
"payload": {"workflow_id": "wf-1", "step_id": "hooked", "phase": "after"},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.core.api.application.request_service import RequestService
|
||||
from app.core.api.application.request_start_gate import RequestStartGate
|
||||
from app.core.api.application.stream_service import StreamService
|
||||
from app.core.api.infrastructure.ids.request_id_factory import RequestIdFactory
|
||||
from app.core.api.infrastructure.stores.in_memory_request_store import InMemoryRequestStore
|
||||
from app.core.api.infrastructure.streaming.sse_event_channel import SseEventChannel
|
||||
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self) -> None:
|
||||
self.started = asyncio.Event()
|
||||
self.calls: list[tuple[object, object]] = []
|
||||
|
||||
async def run(self, request, session) -> None:
|
||||
self.calls.append((request, session))
|
||||
self.started.set()
|
||||
|
||||
|
||||
class FakeSessions:
|
||||
def get(self, _session_id: str):
|
||||
return SimpleNamespace(session_id="sess-1")
|
||||
|
||||
|
||||
async def _wait_briefly() -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
def test_request_service_waits_for_stream_subscriber_before_runtime_start() -> None:
|
||||
gate = RequestStartGate(timeout_seconds=1.0)
|
||||
runtime = FakeRuntime()
|
||||
service = RequestService(
|
||||
request_store=InMemoryRequestStore(),
|
||||
request_ids=RequestIdFactory(),
|
||||
sessions=FakeSessions(),
|
||||
runtime=runtime,
|
||||
start_gate=gate,
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
request = await service.create("sess-1", "hello", "v2")
|
||||
await _wait_briefly()
|
||||
assert runtime.calls == []
|
||||
gate.mark_ready(request.request_id)
|
||||
await asyncio.wait_for(runtime.started.wait(), timeout=1.0)
|
||||
assert len(runtime.calls) == 1
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_stream_service_subscribe_marks_request_ready() -> None:
|
||||
gate = RequestStartGate(timeout_seconds=1.0)
|
||||
gate.register("req-1")
|
||||
service = StreamService(
|
||||
channel=SseEventChannel(),
|
||||
request_exists=lambda request_id: request_id == "req-1",
|
||||
start_gate=gate,
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
waiter = asyncio.create_task(gate.wait_until_ready("req-1"))
|
||||
await service.subscribe("req-1")
|
||||
await asyncio.wait_for(waiter, timeout=1.0)
|
||||
|
||||
asyncio.run(scenario())
|
||||
Reference in New Issue
Block a user