Фиксирую рабочее состояние

This commit is contained in:
2026-04-15 14:20:27 +03:00
parent 7f22a00696
commit 77851e99a7
42 changed files with 1066 additions and 123 deletions
@@ -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 == []
+32
View File
@@ -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())