Роутер работает нормально в process v2

This commit is contained in:
2026-04-07 14:09:51 +03:00
parent 5d77ab1a88
commit 6b74d410cd
1748 changed files with 216679 additions and 14208 deletions
+3
View File
@@ -0,0 +1,3 @@
from app.core.agent.runtime import AgentRuntime
__all__ = ["AgentRuntime"]
+10
View File
@@ -0,0 +1,10 @@
from app.core.agent.processes.base import AgentProcess, ProcessResult
from app.core.agent.processes.v1.process import V1Process
from app.core.agent.processes.v2.process import V2Process
__all__ = [
"AgentProcess",
"ProcessResult",
"V1Process",
"V2Process",
]
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
@dataclass(slots=True)
class ProcessResult:
answer: str = ""
class AgentProcess(ABC):
version = ""
@abstractmethod
async def run(self, context: "RuntimeExecutionContext") -> ProcessResult:
raise NotImplementedError
@@ -0,0 +1,3 @@
from app.core.agent.processes.v1.process import V1Process
__all__ = ["V1Process"]
@@ -0,0 +1,22 @@
from __future__ import annotations
from app.core.agent.processes.base import AgentProcess, ProcessResult
from app.core.agent.processes.v1.workflow import V1FlowMainGraph
from app.core.agent.processes.v1.workflow.flow_main import V1FlowContext
from app.core.agent.utils.llm import AgentLlmService
class V1Process(AgentProcess):
version = "v1"
def __init__(self, llm: AgentLlmService, prompt_name: str = "v1_flow_main.answer") -> None:
self._prompt_name = prompt_name
self._workflow = V1FlowMainGraph(llm)
async def run(self, context) -> ProcessResult:
flow_context = V1FlowContext(
runtime=context,
prompt_name=self._prompt_name,
)
flow_context = await self._workflow.run(flow_context)
return ProcessResult(answer=flow_context.answer)
@@ -0,0 +1,3 @@
from app.core.agent.processes.v1.workflow.flow_main.graph import V1FlowMainGraph
__all__ = ["V1FlowMainGraph"]
@@ -0,0 +1,7 @@
from app.core.agent.processes.v1.workflow.flow_main.context import V1FlowContext
from app.core.agent.processes.v1.workflow.flow_main.graph import V1FlowMainGraph
__all__ = [
"V1FlowContext",
"V1FlowMainGraph",
]
@@ -0,0 +1,13 @@
from __future__ import annotations
from dataclasses import dataclass
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
@dataclass(slots=True)
class V1FlowContext:
runtime: RuntimeExecutionContext
prompt_name: str
prepared_message: str = ""
answer: str = ""
@@ -0,0 +1,24 @@
from __future__ import annotations
from app.core.agent.processes.v1.workflow.flow_main.context import V1FlowContext
from app.core.agent.processes.v1.workflow.flow_main.steps.finalize_answer_step import FinalizeAnswerStep
from app.core.agent.processes.v1.workflow.flow_main.steps.generate_answer_step import GenerateAnswerStep
from app.core.agent.processes.v1.workflow.flow_main.steps.prepare_user_message_step import PrepareUserMessageStep
from app.core.agent.utils.llm import AgentLlmService
from app.core.agent.utils.workflow import WorkflowGraph
class V1FlowMainGraph:
def __init__(self, llm: AgentLlmService) -> None:
self._graph = WorkflowGraph(
workflow_id="v1.flow_main",
source="workflow.v1",
steps=(
PrepareUserMessageStep(),
GenerateAnswerStep(llm),
FinalizeAnswerStep(),
),
)
async def run(self, context: V1FlowContext) -> V1FlowContext:
return await self._graph.run(context)
@@ -0,0 +1,8 @@
namespace: v1_flow_main
prompts:
answer: |
Ты полезный ассистент.
Ответь на сообщение пользователя по существу.
Не придумывай факты, если данных недостаточно.
Если пользователь пишет по-русски, отвечай по-русски.
@@ -0,0 +1,9 @@
from app.core.agent.processes.v1.workflow.flow_main.steps.finalize_answer_step import FinalizeAnswerStep
from app.core.agent.processes.v1.workflow.flow_main.steps.generate_answer_step import GenerateAnswerStep
from app.core.agent.processes.v1.workflow.flow_main.steps.prepare_user_message_step import PrepareUserMessageStep
__all__ = [
"FinalizeAnswerStep",
"GenerateAnswerStep",
"PrepareUserMessageStep",
]
@@ -0,0 +1,19 @@
from __future__ import annotations
from app.core.agent.processes.v1.workflow.flow_main.context import V1FlowContext
from app.core.agent.utils.workflow import WorkflowStep
class FinalizeAnswerStep(WorkflowStep[V1FlowContext]):
step_id = "finalize_answer"
title = "Финализация ответа"
async def run(self, context: V1FlowContext) -> V1FlowContext:
context.answer = context.answer.strip()
return context
def trace_input(self, context: V1FlowContext) -> dict[str, object]:
return {"answer_length_before_strip": len(context.answer)}
def trace_output(self, context: V1FlowContext) -> dict[str, object]:
return {"answer_length": len(context.answer)}
@@ -0,0 +1,32 @@
from __future__ import annotations
import asyncio
from app.core.agent.processes.v1.workflow.flow_main.context import V1FlowContext
from app.core.agent.utils.llm import AgentLlmService
from app.core.agent.utils.workflow import WorkflowStep
class GenerateAnswerStep(WorkflowStep[V1FlowContext]):
step_id = "generate_answer"
title = "Вызов LLM"
def __init__(self, llm: AgentLlmService) -> None:
self._llm = llm
async def run(self, context: V1FlowContext) -> V1FlowContext:
request_id = context.runtime.request.request_id
context.answer = await asyncio.to_thread(
self._llm.generate,
context.prompt_name,
context.prepared_message,
log_context=f"agent:{request_id}",
trace=context.runtime.trace.module("workflow.v1.llm"),
)
return context
def trace_input(self, context: V1FlowContext) -> dict[str, object]:
return {"prompt_name": context.prompt_name, "prepared_message_length": len(context.prepared_message)}
def trace_output(self, context: V1FlowContext) -> dict[str, object]:
return {"answer_length": len(context.answer)}
@@ -0,0 +1,16 @@
from __future__ import annotations
from app.core.agent.processes.v1.workflow.flow_main.context import V1FlowContext
from app.core.agent.utils.workflow import WorkflowStep
class PrepareUserMessageStep(WorkflowStep[V1FlowContext]):
step_id = "prepare_user_message"
title = "Подготовка сообщения"
async def run(self, context: V1FlowContext) -> V1FlowContext:
context.prepared_message = context.runtime.request.message.strip()
return context
def trace_output(self, context: V1FlowContext) -> dict[str, object]:
return {"prepared_message_length": len(context.prepared_message)}
@@ -0,0 +1,4 @@
from app.core.agent.processes.v2.process import V2Process
from app.core.agent.processes.v2.intent_router.router import V2IntentRouter
__all__ = ["V2IntentRouter", "V2Process"]
@@ -0,0 +1,48 @@
from __future__ import annotations
from app.core.agent.processes.v2.models import V2AnchorType, V2RouteAnchors, V2RouteResult, V2Subintent
def anchor_signal_types(route: V2RouteResult) -> set[str]:
hints = [str(item).strip().lower() for item in route.anchors.target_doc_hints if str(item or "").strip()]
signals: set[str] = set()
if route.subintent == V2Subintent.FIND_FILES:
signals.add(V2AnchorType.FIND_FILES)
if route.anchors.endpoint_paths or _has_hint(hints, "/api/"):
signals.add(V2AnchorType.API_ENDPOINT)
if _has_hint(hints, "/architecture/"):
signals.add(V2AnchorType.ARCHITECTURE)
if _has_hint(hints, "/logic/"):
signals.add(V2AnchorType.LOGIC_FLOW)
if _has_hint(hints, "/domains/"):
signals.add(V2AnchorType.DOMAIN_ENTITY)
return signals
def route_anchor_summary(route: V2RouteResult) -> dict[str, object]:
return {
"entity_names": list(route.anchors.entity_names),
"file_names": list(route.anchors.file_names),
"endpoint_paths": list(route.anchors.endpoint_paths),
"target_doc_hints": list(route.anchors.target_doc_hints),
"matched_aliases": list(route.anchors.matched_aliases),
"process_domain": route.anchors.process_domain,
"process_subdomain": route.anchors.process_subdomain,
"signal_types": sorted(anchor_signal_types(route)),
}
def anchors_have_signal(anchors: V2RouteAnchors, signal: str, *, subintent: str | None = None) -> bool:
route = V2RouteResult(
routing_domain="",
intent="",
subintent=subintent or "",
user_query="",
normalized_query="",
anchors=anchors,
)
return signal in anchor_signal_types(route)
def _has_hint(hints: list[str], marker: str) -> bool:
return any(marker in hint for hint in hints)
@@ -0,0 +1,226 @@
"""Anchor-aware ranking для summary и find-files evidence."""
from __future__ import annotations
import re
from app.core.agent.processes.v2.anchor_signals import anchor_signal_types
from app.core.agent.processes.v2.models import RetrievedFile, RetrievedSummary, V2AnchorType, V2RouteResult
from app.core.agent.processes.v2.retrieval.target_doc_seeding import normalize_doc_path
from app.core.rag.contracts.enums import RagLayer
class DocsEvidenceAssembler:
def assemble_summaries(self, rows: list[dict], route: V2RouteResult) -> list[RetrievedSummary]:
items = self._rank_rows(rows, route, mode="summary")
ranked = [
RetrievedSummary(
path=item["path"],
title=item["title"],
summary=item["summary"],
document_id=item["document_id"],
score=item["score"],
confidence=min(1.0, item["score"] / 1000.0),
match_reason=item["match_reason"],
score_breakdown=item["score_breakdown"],
)
for item in items
if item["summary"] and self._summary_row_allowed(item["row"])
]
if ranked:
ranked[0].is_primary = True
return ranked[:3]
def assemble_files(self, rows: list[dict], route: V2RouteResult) -> list[RetrievedFile]:
items = self._rank_rows(rows, route, mode="find_files")
ranked = [
RetrievedFile(
path=item["path"],
title=item["title"],
document_id=item["document_id"],
score=item["score"],
confidence=min(1.0, item["score"] / 1000.0),
match_reason=item["match_reason"],
score_breakdown=item["score_breakdown"],
)
for item in items
]
if ranked:
ranked[0].is_primary = True
return ranked[:4]
def _rank_rows(self, rows: list[dict], route: V2RouteResult, *, mode: str) -> list[dict]:
seen: set[str] = set()
ranked: list[dict] = []
for row in rows:
path = self._path(row)
if not path or path in seen:
continue
seen.add(path)
breakdown = self._score_breakdown(row, route, mode=mode)
score = sum(breakdown.values())
if score <= 0:
continue
ranked.append(
{
"row": row,
"path": path,
"title": self._title(row, path),
"summary": self._summary(row),
"document_id": self._document_id(row, path),
"score": score,
"score_breakdown": breakdown,
"match_reason": self._match_reason(breakdown),
}
)
ranked.sort(key=lambda item: (-item["score"], item["path"]))
return self._ensure_target_docs_in_top_k(ranked, route, k=4 if mode == "find_files" else 3)
def _score_breakdown(self, row: dict, route: V2RouteResult, *, mode: str) -> dict[str, int]:
path_raw = self._path(row)
path = path_raw.lower()
filename = path.split("/")[-1]
title = self._title(row, path).lower()
summary = self._summary(row).lower()
entity = self._entity_name(row).lower()
query_tokens = self._query_tokens(route)
path_tokens = self._path_tokens(path)
compact_haystack = {self._compact(path), self._compact(filename), self._compact(title), self._compact(entity)}
breakdown = {
"semantic": 0,
"path_match": 0,
"filename_match": 0,
"alias_match": 0,
"anchor_boost": 0,
"target_doc_boost": 0,
"generic_penalty": 0,
}
if route.intent == "GENERAL_QA":
breakdown["semantic"] += 80
hint_norm_lower = {normalize_doc_path(h).lower() for h in route.anchors.target_doc_hints if str(h or "").strip()}
if normalize_doc_path(path_raw).lower() in hint_norm_lower:
breakdown["target_doc_boost"] += 1000
if any(alias.lower() in " ".join([path, title, summary, entity]) for alias in route.anchors.matched_aliases):
breakdown["alias_match"] += 500
for token in query_tokens:
if token in path_tokens:
breakdown["path_match"] += 60
if token and token in filename:
breakdown["filename_match"] += 200
if token and token in summary:
breakdown["semantic"] += 20
if self._compact(token) in compact_haystack:
breakdown["alias_match"] += 250
if any(endpoint.strip("/").lower() in filename for endpoint in route.anchors.endpoint_paths):
breakdown["filename_match"] += 200
signals = anchor_signal_types(route)
breakdown["anchor_boost"] += self._anchor_boost(path, signals)
breakdown["generic_penalty"] += self._generic_penalty(path, signals)
if mode == "find_files":
breakdown["path_match"] *= 3
breakdown["filename_match"] *= 2
breakdown["alias_match"] *= 1
breakdown["semantic"] = max(0, breakdown["semantic"] // 2)
return breakdown
def _anchor_boost(self, path: str, signals: set[str]) -> int:
boost = 0
if V2AnchorType.API_ENDPOINT in signals and path.startswith("docs/api/"):
boost += 300
if V2AnchorType.LOGIC_FLOW in signals and path.startswith("docs/logic/"):
boost += 300
if V2AnchorType.DOMAIN_ENTITY in signals and path.startswith("docs/domains/"):
boost += 300
if V2AnchorType.ARCHITECTURE in signals and path.startswith("docs/architecture/"):
boost += 300
if V2AnchorType.FIND_FILES in signals and path.startswith("docs/"):
boost += 120
return boost
def _generic_penalty(self, path: str, signals: set[str]) -> int:
penalty = 0
if path == "docs/README.md" and V2AnchorType.ARCHITECTURE not in signals:
penalty -= 200
if "/architecture/" in path and V2AnchorType.ARCHITECTURE not in signals and signals.intersection(
{V2AnchorType.API_ENDPOINT, V2AnchorType.DOMAIN_ENTITY}
):
penalty -= 150
return penalty
def _ensure_target_docs_in_top_k(self, ranked: list[dict], route: V2RouteResult, *, k: int) -> list[dict]:
if not ranked or not route.anchors.target_doc_hints:
return ranked
top = ranked[:k]
top_paths = {item["path"] for item in top}
top_norm = {normalize_doc_path(p).lower() for p in top_paths if p}
for hint in route.anchors.target_doc_hints:
hn = normalize_doc_path(hint).lower()
if hn in top_norm:
continue
candidate = next(
(item for item in ranked if normalize_doc_path(item["path"]).lower() == hn),
None,
)
if candidate is None:
continue
if len(top) < k:
top.append(candidate)
else:
top[-1] = candidate
top_paths = {item["path"] for item in top}
top_norm = {normalize_doc_path(p).lower() for p in top_paths if p}
remaining = [item for item in ranked if item["path"] not in top_paths]
top.sort(key=lambda item: (-item["score"], item["path"]))
return top + remaining
def _match_reason(self, breakdown: dict[str, int]) -> str:
if breakdown["target_doc_boost"] > 0:
return "exact_path"
if breakdown["alias_match"] > 0:
return "alias_match"
if breakdown["filename_match"] > 0:
return "exact_title"
return "semantic_match"
def _summary_row_allowed(self, row: dict) -> bool:
metadata = dict(row.get("metadata") or {})
if row.get("layer") != RagLayer.DOCS_DOC_CHUNKS:
return True
section = str(metadata.get("section_path") or "").lower()
return "summary" in section or "свод" in section or "overview" in section
def _query_tokens(self, route: V2RouteResult) -> list[str]:
values = list(route.target_terms) + list(route.anchors.matched_aliases)
tokens: list[str] = []
for item in values:
for token in re.split(r"[^a-zA-Zа-яА-Я0-9]+", str(item).lower()):
if len(token) >= 3:
tokens.append(token)
return list(dict.fromkeys(tokens))
def _path_tokens(self, path: str) -> set[str]:
return {token for token in re.split(r"[^a-zA-Zа-яА-Я0-9]+", path.lower()) if len(token) >= 3}
def _compact(self, value: str) -> str:
return "".join(self._path_tokens(value))
def _path(self, row: dict) -> str:
metadata = dict(row.get("metadata") or {})
raw = str(row.get("path") or metadata.get("source_path") or "").strip()
return normalize_doc_path(raw)
def _title(self, row: dict, path: str) -> str:
metadata = dict(row.get("metadata") or {})
return str(row.get("title") or metadata.get("title") or path).strip()
def _summary(self, row: dict) -> str:
metadata = dict(row.get("metadata") or {})
return str(metadata.get("summary_text") or row.get("content") or "").strip()
def _document_id(self, row: dict, path: str) -> str:
metadata = dict(row.get("metadata") or {})
return str(metadata.get("document_id") or metadata.get("doc_id") or path).strip()
def _entity_name(self, row: dict) -> str:
metadata = dict(row.get("metadata") or {})
return str(metadata.get("entity_name") or "").strip()
@@ -0,0 +1,76 @@
from __future__ import annotations
from dataclasses import dataclass, field
from app.core.agent.processes.v2.anchor_signals import anchor_signal_types
from app.core.agent.processes.v2.models import RetrievedFile, RetrievedSummary, V2AnchorType, V2Intent, V2RouteResult
@dataclass(slots=True)
class EvidenceGateDecision:
passed: bool
answer_mode: str
reason: str
message: str = ""
supporting_paths: list[str] = field(default_factory=list)
class DocsEvidenceGate:
def check_summaries(self, route: V2RouteResult, documents: list[RetrievedSummary]) -> EvidenceGateDecision:
if route.intent == V2Intent.GENERAL_QA:
if documents:
return EvidenceGateDecision(True, "grounded_summary", "general_docs_found")
return EvidenceGateDecision(
False,
"insufficient_evidence",
"general_docs_missing",
"В найденной документации нет достаточной опоры для общего summary по запросу.",
)
if self._has_target_document(route, [item.path for item in documents]):
return EvidenceGateDecision(True, "grounded_summary", "target_doc_found")
return EvidenceGateDecision(
False,
"insufficient_evidence",
"target_doc_missing",
self._summary_insufficiency(route, documents),
[item.path for item in documents[:3]],
)
def check_files(self, route: V2RouteResult, files: list[RetrievedFile]) -> EvidenceGateDecision:
if not files:
return EvidenceGateDecision(
False,
"insufficient_evidence",
"no_file_candidates",
"Не нашёл файлов документации, которые уверенно соответствуют запросу.",
)
if files[0].confidence >= 0.8:
return EvidenceGateDecision(True, "deterministic", "primary_file_confident")
return EvidenceGateDecision(
False,
"deterministic",
"low_confidence_shortlist",
"Нашёл только ближайшие кандидаты по запросу.",
[item.path for item in files[:4]],
)
def _has_target_document(self, route: V2RouteResult, paths: list[str]) -> bool:
if any(path in route.anchors.target_doc_hints for path in paths):
return True
signals = anchor_signal_types(route)
if V2AnchorType.API_ENDPOINT in signals:
return any(path.startswith("docs/api/") for path in paths)
if V2AnchorType.ARCHITECTURE in signals:
return any(path.startswith("docs/architecture/") for path in paths)
if V2AnchorType.LOGIC_FLOW in signals:
return any(path.startswith("docs/logic/") for path in paths)
if V2AnchorType.DOMAIN_ENTITY in signals:
return any(path.startswith("docs/domains/") for path in paths)
return bool(paths)
def _summary_insufficiency(self, route: V2RouteResult, documents: list[RetrievedSummary]) -> str:
base = "В поднятом контексте не найден целевой документ по запросу."
if not documents:
return base
nearby = ", ".join(item.path for item in documents[:3])
return f"{base} Ближайшие документы: {nearby}."
@@ -0,0 +1,8 @@
namespace: v2_general
prompts:
summary_answer: |
Ты делаешь grounded summary только по найденной проектной документации.
Не используй общие знания о том, как обычно устроены системы.
Дай короткий, понятный ответ и опирайся только на входные документы.
Если опоры мало, прямо скажи об этом.
@@ -0,0 +1,3 @@
from app.core.agent.processes.v2.intent_router.router import V2IntentRouter
__all__ = ["V2IntentRouter"]
@@ -0,0 +1,17 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(slots=True)
class QueryFeatures:
normalized_query: str
target_terms: list[str]
endpoint_paths: list[str]
matched_aliases: list[str]
target_doc_hints: list[str]
file_markers: list[str]
architecture_markers: list[str]
logic_markers: list[str]
domain_markers: list[str]
endpoint_markers: list[str]
@@ -0,0 +1,11 @@
from app.core.agent.processes.v2.intent_router.modules.anchors import AnchorAnalysis, V2AnchorExtractor
from app.core.agent.processes.v2.intent_router.modules.normalizer import V2QueryNormalizer
from app.core.agent.processes.v2.intent_router.modules.target_terms import TargetTermsAnalysis, V2TargetTermsExtractor
__all__ = [
"AnchorAnalysis",
"TargetTermsAnalysis",
"V2AnchorExtractor",
"V2QueryNormalizer",
"V2TargetTermsExtractor",
]
@@ -0,0 +1,157 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from app.core.agent.processes.v2.intent_router.modules.target_terms import TargetTermsAnalysis
from app.core.agent.processes.v2.models import V2RouteAnchors
@dataclass(slots=True)
class AnchorAnalysis:
anchors: V2RouteAnchors
file_markers: list[str]
architecture_markers: list[str]
logic_markers: list[str]
domain_markers: list[str]
endpoint_markers: list[str]
class _MarkerScanner:
_FILE_MARKERS = (
"в каком файле",
"в каком документе",
"в каких файлах",
"где находится",
"где описан",
"где описана",
"где описаны",
"покажи файл",
"какие файлы",
"найди файл",
"найди файлы",
"покажи документ",
"где описано",
"документ с описанием",
)
_ARCHITECTURE_MARKERS = ("архитектура", "как устроено приложение", "как устроен сервис", "основные части системы", "из чего состоит")
_LOGIC_MARKERS = ("цикл", "loop", "worker", "как работает отправка уведомлений", "логика отправки", "background job", "runtime loop")
_DOMAIN_MARKERS = ("runtime health", "health model", "статусы здоровья", "сущность", "entity", "здоровье runtime")
_ENDPOINT_MARKERS = ("endpoint", "метод api", "ручка", "эндпоинт")
def scan(self, lowered_query: str) -> dict[str, list[str]]:
return {
"file_markers": self._matching(lowered_query, self._FILE_MARKERS),
"architecture_markers": self._matching(lowered_query, self._ARCHITECTURE_MARKERS),
"logic_markers": self._matching(lowered_query, self._LOGIC_MARKERS),
"domain_markers": self._matching(lowered_query, self._DOMAIN_MARKERS),
"endpoint_markers": self._matching(lowered_query, self._ENDPOINT_MARKERS),
}
def _matching(self, query: str, markers: tuple[str, ...]) -> list[str]:
return [marker for marker in markers if marker in query]
class _EntityNameExtractor:
_ENTITY_RE = re.compile(r"\b[A-Z][A-Za-z0-9_]+\b")
def extract(self, query: str) -> list[str]:
items: list[str] = []
for match in self._ENTITY_RE.finditer(query):
candidate = match.group(0).strip()
if candidate and candidate not in items:
items.append(candidate)
return items
class _FileNameExtractor:
_TOKEN_RE = re.compile(r"`([^`]+)`|([A-Za-z0-9_./-]+)")
_WITH_EXTENSION_RE = re.compile(r".+\.(md|yaml|yml|json)$", re.IGNORECASE)
_DOC_PATH_RE = re.compile(r"^(docs|doc|documentation)/.+")
def extract(self, query: str) -> list[str]:
items: list[str] = []
for match in self._TOKEN_RE.finditer(query):
candidate = next((item for item in match.groups() if item), "")
normalized = str(candidate or "").strip().strip("`'\"")
if self._is_file_name(normalized):
self._append_unique(items, normalized.lower())
return items
def _is_file_name(self, token: str) -> bool:
if not token:
return False
if token.startswith("/") and "." not in token:
return False
if self._WITH_EXTENSION_RE.fullmatch(token):
return True
return self._DOC_PATH_RE.fullmatch(token) is not None
def _append_unique(self, items: list[str], value: str) -> None:
if value and value not in items:
items.append(value)
class V2AnchorExtractor:
def __init__(
self,
marker_scanner: _MarkerScanner | None = None,
entity_extractor: _EntityNameExtractor | None = None,
file_name_extractor: _FileNameExtractor | None = None,
) -> None:
self._marker_scanner = marker_scanner or _MarkerScanner()
self._entity_extractor = entity_extractor or _EntityNameExtractor()
self._file_name_extractor = file_name_extractor or _FileNameExtractor()
def extract(self, normalized_query: str, terms: TargetTermsAnalysis) -> AnchorAnalysis:
markers = self._marker_scanner.scan(normalized_query.lower())
anchors = V2RouteAnchors(
entity_names=self._entity_extractor.extract(normalized_query),
file_names=self._file_name_extractor.extract(normalized_query),
endpoint_paths=list(terms.endpoint_paths),
target_doc_hints=self._target_doc_hints(
endpoint_paths=terms.endpoint_paths,
alias_docs=terms.alias_docs,
architecture_markers=markers["architecture_markers"],
logic_markers=markers["logic_markers"],
domain_markers=markers["domain_markers"],
),
matched_aliases=list(terms.matched_aliases),
process_domain=None,
process_subdomain=None,
)
return AnchorAnalysis(
anchors=anchors,
file_markers=markers["file_markers"],
architecture_markers=markers["architecture_markers"],
logic_markers=markers["logic_markers"],
domain_markers=markers["domain_markers"],
endpoint_markers=markers["endpoint_markers"],
)
def _target_doc_hints(
self,
*,
endpoint_paths: list[str],
alias_docs: list[str],
architecture_markers: list[str],
logic_markers: list[str],
domain_markers: list[str],
) -> list[str]:
hints = list(alias_docs)
endpoint_map = {
"/health": "docs/api/health-endpoint.md",
"/send": "docs/api/send-message-endpoint.md",
"/actions/{action}": "docs/api/control-actions-endpoint.md",
}
for endpoint in endpoint_paths:
hint = endpoint_map.get(endpoint)
if hint and hint not in hints:
hints.append(hint)
if architecture_markers and "docs/architecture/telegram-notify-app-overview.md" not in hints:
hints.append("docs/architecture/telegram-notify-app-overview.md")
if logic_markers and "docs/logic/telegram-notification-loop.md" not in hints:
hints.append("docs/logic/telegram-notification-loop.md")
if domain_markers and "docs/domains/runtime-health-entity.md" not in hints:
hints.append("docs/domains/runtime-health-entity.md")
return hints
@@ -0,0 +1,6 @@
from __future__ import annotations
class V2QueryNormalizer:
def normalize(self, user_query: str) -> str:
return " ".join(str(user_query or "").strip().split())
@@ -0,0 +1,209 @@
from __future__ import annotations
import re
from dataclasses import dataclass
@dataclass(slots=True)
class TargetTermsAnalysis:
target_terms: list[str]
endpoint_paths: list[str]
matched_aliases: list[str]
alias_docs: list[str]
@dataclass(frozen=True, slots=True)
class _AliasRule:
phrases: tuple[str, ...]
canonical_term: str
target_doc_hint: str
class _AliasMatcher:
_RULES = (
_AliasRule(("ручная отправка сообщения", "отправка сообщения вручную"), "/send", "docs/api/send-message-endpoint.md"),
_AliasRule(("статус сервиса", "проверка здоровья"), "/health", "docs/api/health-endpoint.md"),
_AliasRule(("control actions", "управление runtime"), "/actions/{action}", "docs/api/control-actions-endpoint.md"),
_AliasRule(("runtime health", "здоровье runtime", "статусы здоровья"), "runtime_health", "docs/domains/runtime-health-entity.md"),
_AliasRule(("цикл отправки уведомлений", "notification loop", "worker loop"), "telegram-notify-loop", "docs/logic/telegram-notification-loop.md"),
_AliasRule(("архитектура приложения", "overview"), "architecture_overview", "docs/architecture/telegram-notify-app-overview.md"),
_AliasRule(("архитектура",), "architecture_overview", "docs/architecture/telegram-notify-app-overview.md"),
_AliasRule(("каталог ошибок", "errors catalog"), "errors_catalog", "docs/errors/catalog.yaml"),
_AliasRule(("файл-индекс документации", "docs index", "индекс документации"), "docs_index", "docs/README.md"),
)
def match(self, lowered_query: str) -> tuple[list[str], list[str], list[str]]:
terms: list[str] = []
docs: list[str] = []
aliases: list[str] = []
for rule in self._RULES:
if any(phrase in lowered_query for phrase in rule.phrases):
self._append_unique(terms, rule.canonical_term.lower())
self._append_unique(docs, rule.target_doc_hint)
self._append_unique(aliases, rule.canonical_term.lower())
return terms, docs, aliases
def _append_unique(self, items: list[str], value: str) -> None:
if value and value not in items:
items.append(value)
class _EndpointPathExtractor:
_PATH_RE = re.compile(r"`([^`]+)`|(/[A-Za-z0-9_./{}-]+)")
_VALID_ENDPOINT_RE = re.compile(r"^/[a-z0-9._/-]+(?:/\{[a-z0-9_]+\})?$")
def extract(self, query: str) -> list[str]:
values: list[str] = []
for match in self._PATH_RE.finditer(query):
candidate = next((item for item in match.groups() if item and item.startswith("/")), "")
normalized = self._normalize(candidate)
if self._is_endpoint(normalized):
self._append_unique(values, normalized)
return values
def _normalize(self, token: str) -> str:
trimmed = str(token or "").strip().strip("`'\"()[]!?.,:;")
if "{" in trimmed and "}" not in trimmed:
return ""
return trimmed.lower()
def _is_endpoint(self, token: str) -> bool:
return bool(token and self._VALID_ENDPOINT_RE.fullmatch(token))
def _append_unique(self, items: list[str], value: str) -> None:
if value and value not in items:
items.append(value)
class _TermCollector:
_TOKEN_RE = re.compile(r"[A-Za-zА-Яа-я0-9_./{}-]+")
_IDENTIFIER_RE = re.compile(
r"^(?:[a-z0-9]+(?:[_-][a-z0-9]+)+|[a-z]+[A-Z][A-Za-z0-9]+|(?:[A-Z][a-z0-9]+){2,})$"
)
_QUESTION_WORDS = {"что", "как", "где", "какой", "какие", "каком", "когда", "чего"}
_INTENT_WORDS = {"объясни", "покажи", "найди", "расскажи", "дай", "опиши", "нужен"}
_FILLER_WORDS = {"про", "там", "тут", "плз"}
_MARKER_WORDS = {
"файл",
"файле",
"док",
"дока",
"доках",
"документ",
"описан",
"док-саммари",
"summary",
"саммари",
}
_SERVICE_WORDS = {
"кратко",
"краткий",
"для",
"есть",
"делает",
"работает",
"это",
"этой",
"этого",
"этот",
"документы",
"документация",
"документации",
"файлы",
"путь",
"пути",
"service",
"summary",
"endpoint",
}
_MAX_TERMS = 7
def collect(self, query: str, alias_terms: list[str], endpoint_paths: list[str]) -> list[str]:
explicit_terms: list[str] = []
for value in endpoint_paths:
self._append_unique(explicit_terms, value)
for token in self._TOKEN_RE.findall(query):
normalized = self._normalize(token)
if not normalized:
continue
if self._is_endpoint(normalized) or self._is_identifier(normalized) or self._is_valid_term(normalized):
self._append_unique(explicit_terms, normalized)
alias_bucket = self._collect_alias_terms(alias_terms, explicit_terms)
prioritized = self._prioritize(explicit_terms, alias_bucket)
return prioritized[: self._MAX_TERMS]
def _normalize(self, token: str) -> str:
trimmed = str(token or "").strip().strip("`'\"()[]!?.,:;")
if "{" in trimmed and "}" not in trimmed:
return ""
return trimmed.lower()
def _is_endpoint(self, token: str) -> bool:
return token.startswith("/") and len(token) > 1 and "{" not in token.replace("{", "", 1)
def _is_identifier(self, token: str) -> bool:
return bool(self._IDENTIFIER_RE.fullmatch(token))
def _is_valid_term(self, token: str) -> bool:
if len(token) < 3 or "/" in token or "." in token:
return False
if (
token in self._QUESTION_WORDS
or token in self._INTENT_WORDS
or token in self._FILLER_WORDS
or token in self._MARKER_WORDS
or token in self._SERVICE_WORDS
):
return False
return True
def _collect_alias_terms(self, alias_terms: list[str], explicit_terms: list[str]) -> list[str]:
collected: list[str] = []
explicit_set = set(explicit_terms)
for term in alias_terms:
normalized = self._normalize(term)
if not normalized:
continue
if normalized in explicit_set:
continue
if self._is_identifier(normalized):
parts = [part for part in re.split(r"[_-]", normalized) if part]
if parts and all(part in explicit_set for part in parts):
continue
self._append_unique(collected, normalized)
return collected
def _prioritize(self, explicit_terms: list[str], alias_terms: list[str]) -> list[str]:
terms = explicit_terms + [term for term in alias_terms if term not in explicit_terms]
endpoints = [term for term in terms if self._is_endpoint(term)]
identifiers = [term for term in terms if term not in endpoints and self._is_identifier(term)]
aliases = [term for term in alias_terms if term not in endpoints and term not in identifiers]
other_terms = [term for term in terms if term not in endpoints and term not in identifiers and term not in aliases]
return endpoints + identifiers + aliases + other_terms
def _append_unique(self, items: list[str], value: str) -> None:
if value and value not in items:
items.append(value)
class V2TargetTermsExtractor:
def __init__(
self,
alias_matcher: _AliasMatcher | None = None,
endpoint_extractor: _EndpointPathExtractor | None = None,
term_collector: _TermCollector | None = None,
) -> None:
self._alias_matcher = alias_matcher or _AliasMatcher()
self._endpoint_extractor = endpoint_extractor or _EndpointPathExtractor()
self._term_collector = term_collector or _TermCollector()
def extract(self, normalized_query: str) -> TargetTermsAnalysis:
lowered = normalized_query.lower()
endpoint_paths = self._endpoint_extractor.extract(normalized_query)
alias_terms, alias_docs, alias_hits = self._alias_matcher.match(lowered)
return TargetTermsAnalysis(
target_terms=self._term_collector.collect(normalized_query, alias_terms, endpoint_paths),
endpoint_paths=endpoint_paths,
matched_aliases=alias_hits,
alias_docs=alias_docs,
)
@@ -0,0 +1,101 @@
"""Маршрутизация запроса в домен/интент/subintent и якоря для v2."""
from __future__ import annotations
from app.core.agent.processes.v2.intent_router.modules.anchors import V2AnchorExtractor
from app.core.agent.processes.v2.intent_router.modules.normalizer import V2QueryNormalizer
from app.core.agent.processes.v2.intent_router.modules.target_terms import V2TargetTermsExtractor
from app.core.agent.processes.v2.intent_router.models import QueryFeatures
from app.core.agent.processes.v2.intent_router.routers.confidence import V2ConfidenceAdjuster
from app.core.agent.processes.v2.intent_router.routers.fallback import V2FallbackRouter
from app.core.agent.processes.v2.intent_router.routers.llm import V2LlmRouter
from app.core.agent.processes.v2.intent_router.routers.route_catalog import V2RouteCatalog
from app.core.agent.processes.v2.intent_router.routers.validator import V2RouteValidator
from app.core.agent.processes.v2.models import V2RouteResult
from app.core.agent.utils.llm import AgentLlmService
class V2IntentRouter:
def __init__(
self,
normalizer: V2QueryNormalizer | None = None,
target_terms_extractor: V2TargetTermsExtractor | None = None,
anchor_extractor: V2AnchorExtractor | None = None,
llm: AgentLlmService | None = None,
enable_llm_disambiguation: bool = True,
route_catalog: V2RouteCatalog | None = None,
confidence_adjuster: V2ConfidenceAdjuster | None = None,
) -> None:
self._normalizer = normalizer or V2QueryNormalizer()
self._target_terms_extractor = target_terms_extractor or V2TargetTermsExtractor()
self._anchor_extractor = anchor_extractor or V2AnchorExtractor()
self._catalog = route_catalog or V2RouteCatalog()
self._validator = V2RouteValidator(self._catalog)
self._fallback_router = V2FallbackRouter()
self._confidence_adjuster = confidence_adjuster or V2ConfidenceAdjuster()
self._enable_llm_disambiguation = enable_llm_disambiguation
self._llm_router = V2LlmRouter(llm, catalog=self._catalog) if llm is not None else None
def route(self, user_query: str) -> V2RouteResult:
normalized_query = self._normalizer.normalize(user_query)
target_terms_analysis = self._target_terms_extractor.extract(normalized_query)
anchor_analysis = self._anchor_extractor.extract(normalized_query, target_terms_analysis)
features = QueryFeatures(
normalized_query=normalized_query,
target_terms=list(target_terms_analysis.target_terms),
endpoint_paths=list(target_terms_analysis.endpoint_paths),
matched_aliases=list(target_terms_analysis.matched_aliases),
target_doc_hints=list(anchor_analysis.anchors.target_doc_hints),
file_markers=list(anchor_analysis.file_markers),
architecture_markers=list(anchor_analysis.architecture_markers),
logic_markers=list(anchor_analysis.logic_markers),
domain_markers=list(anchor_analysis.domain_markers),
endpoint_markers=list(anchor_analysis.endpoint_markers),
)
llm_attempted = self._enable_llm_disambiguation and self._llm_router is not None
llm_candidate = self._route_with_llm(
features=features,
anchors=anchor_analysis.anchors,
)
llm_result = self._validator.validate(llm_candidate)
if llm_result is not None:
confidence = self._confidence_adjuster.adjust(float(llm_result["confidence"]), features)
return V2RouteResult(
routing_domain=llm_result["routing_domain"],
intent=llm_result["intent"],
subintent=llm_result["subintent"],
user_query=user_query,
normalized_query=features.normalized_query,
target_terms=features.target_terms,
anchors=anchor_analysis.anchors,
confidence=confidence,
routing_mode="llm_default",
llm_router_used=True,
reason_short=str(llm_result["reason_short"]),
)
return self._fallback_router.route(
user_query=user_query,
features=features,
anchors=anchor_analysis.anchors,
llm_attempted=llm_attempted,
)
def _route_with_llm(self, *, features: QueryFeatures, anchors) -> dict | None:
if not self._enable_llm_disambiguation or self._llm_router is None:
return None
try:
return self._llm_router.classify(
normalized_query=features.normalized_query,
target_terms=features.target_terms,
anchors={
"entity_names": anchors.entity_names,
"file_names": anchors.file_names,
"endpoint_paths": anchors.endpoint_paths,
"target_doc_hints": anchors.target_doc_hints,
"matched_aliases": anchors.matched_aliases,
"process_domain": anchors.process_domain,
"process_subdomain": anchors.process_subdomain,
},
)
except Exception:
return None
@@ -0,0 +1,5 @@
from app.core.agent.processes.v2.intent_router.routers.docs_subintent_resolver import DocsSubintentResolver
from app.core.agent.processes.v2.intent_router.routers.deterministic import V2DeterministicRouter
from app.core.agent.processes.v2.intent_router.routers.llm import V2LlmRouter
__all__ = ["DocsSubintentResolver", "V2DeterministicRouter", "V2LlmRouter"]
@@ -0,0 +1,25 @@
from __future__ import annotations
from app.core.agent.processes.v2.intent_router.models import QueryFeatures
class V2ConfidenceAdjuster:
def adjust(self, confidence: float, features: QueryFeatures) -> float:
adjusted = confidence
if not self._has_strong_anchor(features):
adjusted -= 0.1
if self._is_short_or_vague(features):
adjusted -= 0.1
if self._has_explicit_signal(features):
adjusted += 0.05
return min(max(adjusted, 0.0), 1.0)
def _has_strong_anchor(self, features: QueryFeatures) -> bool:
return any((features.file_markers, features.endpoint_paths, features.target_doc_hints, features.matched_aliases))
def _is_short_or_vague(self, features: QueryFeatures) -> bool:
token_count = len([token for token in features.normalized_query.split() if token.strip()])
return token_count <= 3 or len(features.target_terms) <= 1
def _has_explicit_signal(self, features: QueryFeatures) -> bool:
return bool(features.file_markers or features.endpoint_paths or features.endpoint_markers)
@@ -0,0 +1,73 @@
from __future__ import annotations
from app.core.agent.processes.v2.intent_router.models import QueryFeatures
from app.core.agent.processes.v2.models import V2Domain, V2Intent, V2RouteResult, V2Subintent
from app.core.agent.processes.v2.intent_router.routers.docs_subintent_resolver import DocsSubintentResolver
class V2DeterministicRouter:
_GENERAL_MARKERS = (
"что это за сервис",
"для чего нужен",
"какую задачу решает",
"что входит в документацию",
"какие документы стоит читать сначала",
"дай короткое summary",
"с чего начать",
"что тут есть кроме api",
"как в целом устроено приложение",
"какие основные части есть",
"из чего состоит telegram notify app",
)
def __init__(self, subintent_resolver: DocsSubintentResolver | None = None) -> None:
self._subintent_resolver = subintent_resolver or DocsSubintentResolver()
def route(self, user_query: str, features: QueryFeatures, anchors) -> V2RouteResult | None:
subintent = self._subintent_resolver.resolve(features)
if subintent == V2Subintent.FIND_FILES:
return self._build_docs_route(user_query, features, anchors, subintent, "deterministic file anchor")
if subintent is not None and not self._has_conflicting_doc_anchors(features):
return self._build_docs_route(user_query, features, anchors, subintent, "deterministic signal")
if self._is_general_summary(features.normalized_query):
return V2RouteResult(
routing_domain=V2Domain.GENERAL,
intent=V2Intent.GENERAL_QA,
subintent=V2Subintent.SUMMARY,
user_query=user_query,
normalized_query=features.normalized_query,
target_terms=features.target_terms,
anchors=anchors,
confidence=1.0,
routing_mode="deterministic",
llm_router_used=False,
reason_short="general fallback signal",
)
return None
def _build_docs_route(self, user_query: str, features: QueryFeatures, anchors, subintent: str, reason: str) -> V2RouteResult:
return V2RouteResult(
routing_domain=V2Domain.DOCS,
intent=V2Intent.DOC_EXPLAIN,
subintent=subintent,
user_query=user_query,
normalized_query=features.normalized_query,
target_terms=features.target_terms,
anchors=anchors,
confidence=1.0,
routing_mode="deterministic",
llm_router_used=False,
reason_short=reason,
)
def _is_general_summary(self, normalized_query: str) -> bool:
query = normalized_query.lower()
return any(marker in query for marker in self._GENERAL_MARKERS)
def _has_conflicting_doc_anchors(self, features: QueryFeatures) -> bool:
signals = 0
signals += 1 if features.endpoint_paths or features.endpoint_markers else 0
signals += 1 if features.architecture_markers else 0
signals += 1 if features.logic_markers else 0
signals += 1 if features.domain_markers else 0
return signals > 1
@@ -0,0 +1,22 @@
from __future__ import annotations
from app.core.agent.processes.v2.intent_router.models import QueryFeatures
from app.core.agent.processes.v2.models import V2Subintent
class DocsSubintentResolver:
def resolve(self, features: QueryFeatures) -> str | None:
if features.file_markers:
return V2Subintent.FIND_FILES
if any(
(
features.endpoint_paths,
features.endpoint_markers,
features.architecture_markers,
features.logic_markers,
features.domain_markers,
features.target_doc_hints,
)
):
return V2Subintent.SUMMARY
return None
@@ -0,0 +1,86 @@
from __future__ import annotations
from app.core.agent.processes.v2.intent_router.models import QueryFeatures
from app.core.agent.processes.v2.models import V2Domain, V2Intent, V2RouteResult, V2Subintent
class V2FallbackRouter:
def route(
self,
*,
user_query: str,
features: QueryFeatures,
anchors,
llm_attempted: bool,
) -> V2RouteResult:
if features.file_markers:
return self._build_docs_result(
user_query=user_query,
features=features,
anchors=anchors,
subintent=V2Subintent.FIND_FILES,
llm_attempted=llm_attempted,
reason="fallback file markers",
)
if self._has_docs_signal(features):
return self._build_docs_result(
user_query=user_query,
features=features,
anchors=anchors,
subintent=V2Subintent.SUMMARY,
llm_attempted=llm_attempted,
reason="fallback docs summary",
)
return V2RouteResult(
routing_domain=V2Domain.GENERAL,
intent=V2Intent.GENERAL_QA,
subintent=V2Subintent.SUMMARY,
user_query=user_query,
normalized_query=features.normalized_query,
target_terms=features.target_terms,
anchors=anchors,
confidence=0.0,
routing_mode=self._routing_mode(llm_attempted),
llm_router_used=llm_attempted,
reason_short="fallback general summary",
)
def _build_docs_result(
self,
*,
user_query: str,
features: QueryFeatures,
anchors,
subintent: str,
llm_attempted: bool,
reason: str,
) -> V2RouteResult:
return V2RouteResult(
routing_domain=V2Domain.DOCS,
intent=V2Intent.DOC_EXPLAIN,
subintent=subintent,
user_query=user_query,
normalized_query=features.normalized_query,
target_terms=features.target_terms,
anchors=anchors,
confidence=0.0,
routing_mode=self._routing_mode(llm_attempted),
llm_router_used=llm_attempted,
reason_short=reason,
)
def _has_docs_signal(self, features: QueryFeatures) -> bool:
return any(
(
features.endpoint_paths,
features.target_doc_hints,
features.endpoint_markers,
features.architecture_markers,
features.logic_markers,
features.domain_markers,
features.matched_aliases,
)
)
def _routing_mode(self, llm_attempted: bool) -> str:
return "llm_fallback" if llm_attempted else "deterministic_fallback"
@@ -0,0 +1,45 @@
from __future__ import annotations
import json
from app.core.agent.processes.v2.intent_router.routers.route_catalog import V2RouteCatalog
from app.core.agent.utils.llm import AgentLlmService
class V2LlmRouter:
def __init__(
self,
llm: AgentLlmService,
prompt_name: str = "v2_intent_router.route",
catalog: V2RouteCatalog | None = None,
) -> None:
self._llm = llm
self._prompt_name = prompt_name
self._catalog = catalog or V2RouteCatalog()
def classify(self, *, normalized_query: str, target_terms: list[str], anchors: dict) -> dict | None:
payload = {
"normalized_query": normalized_query,
"target_terms": target_terms,
"anchors": anchors,
"allowed_routes": self._catalog.allowed_routes(),
}
raw = self._llm.generate(
self._prompt_name,
json.dumps(payload, ensure_ascii=False, indent=2),
log_context="v2_intent_router",
)
return self._parse(raw)
def _parse(self, raw: str) -> dict | None:
try:
data = json.loads(str(raw or "").strip())
except json.JSONDecodeError:
return None
return {
"routing_domain": str(data.get("routing_domain") or "").strip(),
"intent": str(data.get("intent") or "").strip(),
"subintent": str(data.get("subintent") or "").strip(),
"confidence": data.get("confidence"),
"reason_short": str(data.get("reason_short") or "").strip(),
}
@@ -0,0 +1,26 @@
namespace: v2_intent_router
prompts:
route: |
Ты выбираешь маршрут для узкого процесса v2.
Основной принцип:
- DOCS / DOC_EXPLAIN / FIND_FILES: запрос просит найти файл, документ или путь.
- DOCS / DOC_EXPLAIN / SUMMARY: запрос просит объяснить документацию, endpoint, архитектуру, процесс или сущность.
- GENERAL / GENERAL_QA / SUMMARY: общий обзорный вопрос без явного запроса к документации.
Используй только маршруты из поля `allowed_routes`.
Верни confidence:
- 0.9-1.0 для явного кейса
- 0.7-0.9 для нормального кейса
- меньше 0.7 для неоднозначного кейса
Ответь только JSON-объектом вида:
{
"routing_domain": "GENERAL" | "DOCS",
"intent": "GENERAL_QA" | "DOC_EXPLAIN",
"subintent": "SUMMARY" | "FIND_FILES",
"confidence": 0.0-1.0,
"reason_short": "короткая причина"
}
Не добавляй markdown, комментарии и текст вне JSON.
@@ -0,0 +1,20 @@
from __future__ import annotations
from app.core.agent.processes.v2.models import V2Domain, V2Intent, V2Subintent
class V2RouteCatalog:
_ALLOWED_ROUTES = (
(V2Domain.DOCS, V2Intent.DOC_EXPLAIN, V2Subintent.FIND_FILES),
(V2Domain.DOCS, V2Intent.DOC_EXPLAIN, V2Subintent.SUMMARY),
(V2Domain.GENERAL, V2Intent.GENERAL_QA, V2Subintent.SUMMARY),
)
def allowed_routes(self) -> list[dict[str, str]]:
return [
{"routing_domain": domain, "intent": intent, "subintent": subintent}
for domain, intent, subintent in self._ALLOWED_ROUTES
]
def is_allowed(self, routing_domain: str, intent: str, subintent: str) -> bool:
return (routing_domain, intent, subintent) in self._ALLOWED_ROUTES
@@ -0,0 +1,34 @@
from __future__ import annotations
from app.core.agent.processes.v2.intent_router.routers.route_catalog import V2RouteCatalog
class V2RouteValidator:
def __init__(self, catalog: V2RouteCatalog | None = None) -> None:
self._catalog = catalog or V2RouteCatalog()
def validate(self, candidate: dict | None) -> dict | None:
if not isinstance(candidate, dict):
return None
routing_domain = self._value(candidate, "routing_domain")
intent = self._value(candidate, "intent")
subintent = self._value(candidate, "subintent")
if not self._catalog.is_allowed(routing_domain, intent, subintent):
return None
return {
"routing_domain": routing_domain,
"intent": intent,
"subintent": subintent,
"confidence": self._coerce_confidence(candidate.get("confidence")),
"reason_short": self._value(candidate, "reason_short"),
}
def _value(self, candidate: dict, key: str) -> str:
return str(candidate.get(key) or "").strip()
def _coerce_confidence(self, value: object) -> float:
try:
confidence = float(value)
except (TypeError, ValueError):
return 0.0
return max(0.0, min(1.0, confidence))
+87
View File
@@ -0,0 +1,87 @@
"""Типы маршрута и выдачи retrieval для процесса v2."""
from __future__ import annotations
from dataclasses import dataclass, field
class V2Domain:
DOCS = "DOCS"
GENERAL = "GENERAL"
class V2Intent:
DOC_EXPLAIN = "DOC_EXPLAIN"
GENERAL_QA = "GENERAL_QA"
class V2Subintent:
SUMMARY = "SUMMARY"
FIND_FILES = "FIND_FILES"
class V2AnchorType:
GENERAL_OVERVIEW = "GENERAL_OVERVIEW"
API_ENDPOINT = "API_ENDPOINT"
ARCHITECTURE = "ARCHITECTURE"
LOGIC_FLOW = "LOGIC_FLOW"
DOMAIN_ENTITY = "DOMAIN_ENTITY"
FIND_FILES = "FIND_FILES"
@dataclass(slots=True)
class V2RouteAnchors:
"""Якоря из запроса для retrieval и downstream."""
entity_names: list[str] = field(default_factory=list)
file_names: list[str] = field(default_factory=list)
endpoint_paths: list[str] = field(default_factory=list)
target_doc_hints: list[str] = field(default_factory=list)
matched_aliases: list[str] = field(default_factory=list)
process_domain: str | None = None
process_subdomain: str | None = None
@dataclass(slots=True)
class V2RouteResult:
routing_domain: str
intent: str
subintent: str
user_query: str
normalized_query: str
target_terms: list[str] = field(default_factory=list)
anchors: V2RouteAnchors = field(default_factory=V2RouteAnchors)
confidence: float = 1.0
routing_mode: str = "deterministic"
llm_router_used: bool = False
reason_short: str = ""
@property
def domain(self) -> str:
"""Совместимость с полем ``domain`` в логах и вызовах."""
return self.routing_domain
@dataclass(slots=True)
class RetrievedSummary:
path: str
title: str
summary: str
document_id: str
score: int
confidence: float = 0.0
match_reason: str = "semantic_match"
is_primary: bool = False
score_breakdown: dict[str, int] = field(default_factory=dict)
@dataclass(slots=True)
class RetrievedFile:
path: str
title: str
document_id: str
score: int
confidence: float
match_reason: str
is_primary: bool = False
score_breakdown: dict[str, int] = field(default_factory=dict)
+357
View File
@@ -0,0 +1,357 @@
"""Процесс v2: роутинг, план retrieval, вызов rag API, сборка evidence и workflow."""
from __future__ import annotations
from app.core.agent.processes.v2.anchor_signals import route_anchor_summary
from app.core.agent.processes.v2.evidence.assembler import DocsEvidenceAssembler
from app.core.agent.processes.v2.evidence.gate import DocsEvidenceGate
from app.core.agent.processes.v2.intent_router import V2IntentRouter
from app.core.agent.processes.v2.models import V2Intent, V2Subintent
from app.core.agent.processes.v2.retrieval import DocsMetadataLookupIndex
from app.core.agent.processes.v2.retrieval.policy_resolver import V2RetrievalPolicyResolver
from app.core.agent.processes.v2.retrieval.target_doc_seeding import (
RagRowIndex,
merge_row_lists,
normalize_doc_path,
normalized_path_set,
path_variants_for_rag_query,
row_path,
seed_candidates_from_target_hints,
)
from app.core.agent.processes.v2.retrieval.v2_rag_adapter import V2RagRetrievalAdapter
from app.core.agent.processes.v2.workflows.docs_explain_find_files.context import DocsExplainFindFilesContext
from app.core.agent.processes.v2.workflows.docs_explain_find_files.graph import DocsExplainFindFilesGraph
from app.core.agent.processes.v2.workflows.docs_explain_summary.context import DocsExplainSummaryContext
from app.core.agent.processes.v2.workflows.docs_explain_summary.graph import DocsExplainSummaryGraph
from app.core.agent.processes.v2.workflows.general_summary.context import GeneralSummaryContext
from app.core.agent.processes.v2.workflows.general_summary.graph import GeneralSummaryGraph
from app.core.agent.processes.base import AgentProcess, ProcessResult
from app.core.agent.utils.llm import AgentLlmService
class V2Process(AgentProcess):
version = "v2"
def __init__(
self,
llm: AgentLlmService,
policy_resolver: V2RetrievalPolicyResolver,
rag_adapter: V2RagRetrievalAdapter,
evidence_assembler: DocsEvidenceAssembler,
evidence_gate: DocsEvidenceGate | None = None,
router: V2IntentRouter | None = None,
docs_summary_prompt_name: str = "v2_docs_explain.summary_answer",
general_summary_prompt_name: str = "v2_general.summary_answer",
workflow_llm_enabled: bool = True,
) -> None:
self._router = router or V2IntentRouter()
self._policy_resolver = policy_resolver
self._rag_adapter = rag_adapter
self._evidence_assembler = evidence_assembler
self._evidence_gate = evidence_gate or DocsEvidenceGate()
self._docs_summary_prompt_name = docs_summary_prompt_name
self._general_summary_prompt_name = general_summary_prompt_name
self._workflow_llm_enabled = workflow_llm_enabled
self._summary_graph = DocsExplainSummaryGraph(llm)
self._find_files_graph = DocsExplainFindFilesGraph()
self._general_summary_graph = GeneralSummaryGraph(llm)
async def run(self, context) -> ProcessResult:
route = self._router.route(context.request.message)
rag_session_id = context.session.active_rag_session_id
context.trace.module("process.v2").log(
"intent_routed",
{
"routing_domain": route.routing_domain,
"intent": route.intent,
"subintent": route.subintent,
"normalized_query": route.normalized_query,
"target_terms": route.target_terms,
"anchors": route_anchor_summary(route),
"confidence": route.confidence,
"routing_mode": route.routing_mode,
"llm_router_used": route.llm_router_used,
"reason_short": route.reason_short,
"rag_session_id": rag_session_id,
},
)
self._log_step(
context,
"router_resolved",
{
"domain": route.routing_domain,
"intent": route.intent,
"subintent": route.subintent,
"confidence": route.confidence,
},
)
self._log_step(
context,
"anchors_extracted",
{
"signal_types": route_anchor_summary(route)["signal_types"],
"endpoint_paths": route.anchors.endpoint_paths,
"target_doc_hints": route.anchors.target_doc_hints,
"matched_aliases": route.anchors.matched_aliases,
"target_terms": route.target_terms,
},
)
self._log_step(
context,
"alias_resolution",
{
"resolved_aliases": route.anchors.matched_aliases,
"target_doc_hints": route.anchors.target_doc_hints,
},
)
if not rag_session_id:
if route.intent == V2Intent.GENERAL_QA:
answer = "Не могу собрать grounded summary без активной RAG-сессии с проиндексированной документацией."
self._log_step(context, "evidence_gate_checked", {"passed": False, "reason": "missing_rag_session"})
self._log_step(context, "answer_generated", {"answer_mode": "insufficient_evidence"})
return ProcessResult(answer=answer)
return ProcessResult(answer="Для процесса v2 нужна активная RAG-сессия проекта с проиндексированной документацией.")
plan = self._policy_resolver.resolve(route)
context.trace.module("process.v2.retrieval_policy").log(
"retrieval_plan_resolved",
{"profile": plan.profile, "layers": plan.layers, "limit": plan.limit, "filters": plan.filters},
)
self._log_step(
context,
"retrieval_profile_selected",
{"profile": plan.profile, "layers": plan.layers, "filters": plan.filters},
)
seeded_rows = await self._seed_candidates_from_target_hints(rag_session_id, plan.layers, route)
semantic_rows = await self._rag_adapter.fetch_rows(rag_session_id, route.normalized_query, plan)
metadata_rows = self._metadata_lookup_candidates([*seeded_rows, *semantic_rows], route)
rows = self._merge_candidate_rows(seeded_rows, metadata_rows, semantic_rows)
rows = await self._ensure_target_hints_in_pool(rag_session_id, rows, route)
rows = seed_candidates_from_target_hints(rows, route.anchors.target_doc_hints, RagRowIndex(rows))
self._print_missing_target_hints(route, rows)
context.trace.module("process.v2.rag_retrieval").log(
"rag_rows_fetched",
{
"profile": plan.profile,
"row_count": len(rows),
"rows": [self._trace_row(row) for row in rows],
},
)
self._log_step(
context,
"candidate_generation",
{
"query": route.user_query,
"profile": plan.profile,
"details": {
"target_doc_hints": list(route.anchors.target_doc_hints),
"candidates_before_ranking": [row_path(row) for row in rows if row_path(row)],
},
"resolved_aliases": route.anchors.matched_aliases,
"target_doc_hints": route.anchors.target_doc_hints,
"candidate_docs_before_ranking": [self._trace_row(row) for row in rows[:8]],
"sources": {
"seeded": [self._trace_row(row) for row in seeded_rows[:5]],
"metadata_lookup": [self._trace_row(row) for row in metadata_rows[:5]],
"semantic": [self._trace_row(row) for row in semantic_rows[:5]],
},
},
)
self._log_step(
context,
"retrieval_executed",
{
"query": route.user_query,
"profile": plan.profile,
"row_count": len(rows),
"target_doc_hints": route.anchors.target_doc_hints,
"top_results": [self._trace_row(row) for row in rows[:5]],
},
)
if route.subintent == V2Subintent.FIND_FILES:
files = self._evidence_assembler.assemble_files(rows, route)
gate = self._evidence_gate.check_files(route, files)
context.trace.module("process.v2.evidence").log(
"evidence_assembled",
{"mode": "find_files", "file_count": len(files), "files": [file.path for file in files]},
)
self._log_step(
context,
"evidence_assembled",
{"mode": "find_files", "primary_file": files[0].path if files else None, "file_count": len(files)},
)
self._log_ranking(context, files)
self._log_step(
context,
"evidence_gate_checked",
{"passed": gate.passed, "reason": gate.reason, "answer_mode": gate.answer_mode},
)
flow_context = DocsExplainFindFilesContext(
runtime=context,
route=route,
rag_session_id=rag_session_id,
files=files,
gate_decision=gate,
)
flow_context = await self._find_files_graph.run(flow_context)
self._log_step(context, "answer_generated", {"answer_mode": gate.answer_mode, "answer_length": len(flow_context.answer)})
return ProcessResult(answer=flow_context.answer)
documents = self._evidence_assembler.assemble_summaries(rows, route)
gate = self._evidence_gate.check_summaries(route, documents)
context.trace.module("process.v2.evidence").log(
"evidence_assembled",
{"mode": "summary", "document_count": len(documents), "documents": [item.path for item in documents]},
)
self._log_step(
context,
"evidence_assembled",
{"mode": "summary", "primary_doc": documents[0].path if documents else None, "document_count": len(documents)},
)
self._log_ranking(context, documents)
self._log_step(
context,
"evidence_gate_checked",
{"passed": gate.passed, "reason": gate.reason, "answer_mode": gate.answer_mode},
)
if route.intent == V2Intent.GENERAL_QA:
flow_context = GeneralSummaryContext(
runtime=context,
route=route,
prompt_name=self._general_summary_prompt_name,
workflow_llm_enabled=self._workflow_llm_enabled,
documents=documents,
gate_decision=gate,
)
flow_context = await self._general_summary_graph.run(flow_context)
self._log_step(context, "answer_generated", {"answer_mode": gate.answer_mode, "answer_length": len(flow_context.answer)})
return ProcessResult(answer=flow_context.answer)
flow_context = DocsExplainSummaryContext(
runtime=context,
route=route,
rag_session_id=rag_session_id,
prompt_name=self._docs_summary_prompt_name,
workflow_llm_enabled=self._workflow_llm_enabled,
documents=documents,
gate_decision=gate,
)
flow_context = await self._summary_graph.run(flow_context)
self._log_step(context, "answer_generated", {"answer_mode": gate.answer_mode, "answer_length": len(flow_context.answer)})
return ProcessResult(answer=flow_context.answer)
def _trace_row(self, row: dict) -> dict[str, object]:
metadata = row.get("metadata") or {}
content = str(row.get("content") or "").strip()
return {
"layer": str(row.get("layer") or ""),
"path": str(row.get("path") or ""),
"title": str(row.get("title") or ""),
"document_id": str(metadata.get("document_id") or metadata.get("doc_id") or ""),
"entity_name": str(metadata.get("entity_name") or ""),
"summary_text": str(metadata.get("summary_text") or "")[:400],
"section_path": str(metadata.get("section_path") or ""),
"content_preview": content[:400],
}
def _log_step(self, context, step: str, payload: dict[str, object]) -> None:
context.trace.module("process.v2.pipeline").log(step, payload)
def _print_missing_target_hints(self, route, rows: list[dict]) -> None:
if not route.anchors.target_doc_hints:
return
candidate_paths = normalized_path_set(rows)
for hint in route.anchors.target_doc_hints:
if not str(hint or "").strip():
continue
normalized = normalize_doc_path(hint)
if normalized not in candidate_paths:
print("ERROR: target doc missing from candidates:", normalized)
async def _ensure_target_hints_in_pool(self, rag_session_id: str, rows: list[dict], route) -> list[dict]:
hints_raw = [str(item).strip() for item in route.anchors.target_doc_hints if str(item or "").strip()]
if not hints_raw:
return rows
pool = normalized_path_set(rows)
missing_hints = [h for h in hints_raw if normalize_doc_path(h) not in pool]
if not missing_hints:
return rows
variant_paths: list[str] = []
for h in missing_hints:
variant_paths.extend(path_variants_for_rag_query(h))
variant_paths = list(dict.fromkeys(variant_paths))
extra_exact = await self._rag_adapter.fetch_exact_paths(rag_session_id, paths=variant_paths, layers=None)
pool2 = normalized_path_set(extra_exact)
still_missing = [h for h in missing_hints if normalize_doc_path(h) not in pool2]
fallback_rows: list[dict] = []
if still_missing:
needles = [normalize_doc_path(h).split("/")[-1] for h in still_missing]
needles = list(dict.fromkeys(n for n in needles if n))
if needles:
fallback_rows = await self._rag_adapter.fetch_chunks_by_path_substrings(
rag_session_id,
path_needles=needles,
layers=None,
)
return merge_row_lists(rows, extra_exact, fallback_rows)
async def _seed_candidates_from_target_hints(self, rag_session_id: str, layers: list[str], route) -> list[dict]:
del layers # seed по пути должен видеть все слои (иначе D0-only чанки теряются при file_lookup).
hints_raw = [str(item).strip() for item in route.anchors.target_doc_hints if str(item or "").strip()]
if not hints_raw:
return []
variant_paths: list[str] = []
for h in hints_raw:
variant_paths.extend(path_variants_for_rag_query(h))
variant_paths = list(dict.fromkeys(variant_paths))
exact_rows = await self._rag_adapter.fetch_exact_paths(rag_session_id, paths=variant_paths, layers=None)
paths_found = normalized_path_set(exact_rows)
missing = [h for h in hints_raw if normalize_doc_path(h) not in paths_found]
if not missing:
return exact_rows
needles = [normalize_doc_path(h).split("/")[-1] for h in missing]
needles = list(dict.fromkeys(n for n in needles if n))
if not needles:
return exact_rows
fallback_rows = await self._rag_adapter.fetch_chunks_by_path_substrings(
rag_session_id,
path_needles=needles,
layers=None,
)
return merge_row_lists(exact_rows, fallback_rows)
def _metadata_lookup_candidates(self, rows: list[dict], route) -> list[dict]:
return DocsMetadataLookupIndex(rows).lookup(route)
def _merge_candidate_rows(self, *groups: list[dict]) -> list[dict]:
return merge_row_lists(*groups)
def _log_ranking(self, context, items: list) -> None:
top_docs: list[dict[str, object]] = []
for item in items[:4]:
top_docs.append(
{
"doc": getattr(item, "path", ""),
"score": getattr(item, "score", 0),
"match_reason": getattr(item, "match_reason", ""),
}
)
context.trace.module("process.v2.pipeline").log(
"ranking_explained",
{
"doc": getattr(item, "path", ""),
"score_breakdown": getattr(item, "score_breakdown", {}),
"score": getattr(item, "score", 0),
"match_reason": getattr(item, "match_reason", ""),
},
)
context.trace.module("process.v2.pipeline").log(
"ranking_explained",
{
"top_docs_after_ranking": top_docs,
"ranking_score_breakdown": [
{
"doc": getattr(item, "path", ""),
"score_breakdown": getattr(item, "score_breakdown", {}),
}
for item in items[:4]
],
},
)
@@ -0,0 +1,8 @@
namespace: v2_docs_explain
prompts:
summary_answer: |
Ты объясняешь документацию только на основе найденных SUMMARY-блоков.
Используй только факты из входного контекста.
Если информации мало, прямо скажи об этом и не додумывай детали.
В конце перечисли файлы, на которые ты опирался.
@@ -0,0 +1,17 @@
from app.core.agent.processes.v2.retrieval.metadata_lookup import DocsMetadataLookupIndex
from app.core.agent.processes.v2.retrieval.policy_resolver import V2RetrievalPolicyResolver
from app.core.agent.processes.v2.retrieval.target_doc_seeding import (
RagRowIndex,
normalize_doc_path,
seed_candidates_from_target_hints,
)
from app.core.agent.processes.v2.retrieval.v2_rag_adapter import V2RagRetrievalAdapter
__all__ = [
"V2RetrievalPolicyResolver",
"V2RagRetrievalAdapter",
"DocsMetadataLookupIndex",
"normalize_doc_path",
"RagRowIndex",
"seed_candidates_from_target_hints",
]
@@ -0,0 +1,66 @@
from __future__ import annotations
import re
from collections import defaultdict
from app.core.agent.processes.v2.models import V2RouteResult
class DocsMetadataLookupIndex:
def __init__(self, rows: list[dict]) -> None:
self._rows_by_path: dict[str, dict] = {}
self._rows_by_basename: dict[str, list[dict]] = defaultdict(list)
self._rows_by_slug: dict[str, list[dict]] = defaultdict(list)
self._rows_by_title_token: dict[str, list[dict]] = defaultdict(list)
self._rows_by_compact: dict[str, list[dict]] = defaultdict(list)
for row in rows:
path = str(row.get("path") or "").strip()
if not path or path in self._rows_by_path:
continue
self._rows_by_path[path] = row
basename = path.split("/")[-1].lower()
slug = basename.removesuffix(".md").removesuffix(".yaml").removesuffix(".yml")
self._rows_by_basename[basename].append(row)
self._rows_by_slug[slug].append(row)
self._rows_by_compact[self._compact(slug)].append(row)
title = str(row.get("title") or "").lower()
for token in self._tokens(title):
self._rows_by_title_token[token].append(row)
self._rows_by_compact[self._compact(title)].append(row)
entity_name = str(dict(row.get("metadata") or {}).get("entity_name") or "").lower()
if entity_name:
self._rows_by_compact[self._compact(entity_name)].append(row)
def lookup(self, route: V2RouteResult) -> list[dict]:
candidates: list[dict] = []
seen: set[str] = set()
for path in route.anchors.target_doc_hints:
self._append(candidates, seen, self._rows_by_path.get(path))
lookup_tokens = list(route.target_terms) + list(route.anchors.matched_aliases) + list(route.anchors.endpoint_paths)
for token in self._tokens(" ".join(lookup_tokens)):
for bucket in (
self._rows_by_basename.get(token, []),
self._rows_by_slug.get(token, []),
self._rows_by_title_token.get(token, []),
):
for row in bucket:
self._append(candidates, seen, row)
for compact in {self._compact(item) for item in lookup_tokens if item}:
for row in self._rows_by_compact.get(compact, []):
self._append(candidates, seen, row)
return candidates
def _append(self, items: list[dict], seen: set[str], row: dict | None) -> None:
if row is None:
return
path = str(row.get("path") or "").strip()
if not path or path in seen:
return
seen.add(path)
items.append(row)
def _tokens(self, value: str) -> list[str]:
return [token for token in re.split(r"[^a-zA-Zа-яА-Я0-9]+", str(value or "").lower()) if len(token) >= 3]
def _compact(self, value: str) -> str:
return "".join(self._tokens(value))
@@ -0,0 +1,118 @@
"""Intent-aware retrieval policy resolver для процесса v2."""
from __future__ import annotations
from app.core.agent.processes.v2.anchor_signals import anchor_signal_types
from app.core.agent.processes.v2.models import V2AnchorType, V2Intent, V2RouteResult, V2Subintent
from app.core.rag.contracts.enums import RagLayer
from app.core.rag.retrieval.session_retriever import RetrievalPlan
class V2RetrievalPolicyResolver:
_SUMMARY_LAYERS = [
RagLayer.DOCS_DOCUMENT_CATALOG,
RagLayer.DOCS_ENTITY_CATALOG,
RagLayer.DOCS_DOC_CHUNKS,
]
_GENERAL_LAYERS = [
RagLayer.DOCS_DOCUMENT_CATALOG,
RagLayer.DOCS_DOC_CHUNKS,
]
def resolve(self, route: V2RouteResult) -> RetrievalPlan:
if route.intent == V2Intent.GENERAL_QA:
return RetrievalPlan(
profile="general_qa_grounded_summary",
layers=list(self._GENERAL_LAYERS),
limit=8,
filters=self._general_filters(route),
)
if route.subintent == V2Subintent.FIND_FILES:
return RetrievalPlan(
profile="file_lookup",
layers=[RagLayer.DOCS_DOCUMENT_CATALOG, RagLayer.DOCS_ENTITY_CATALOG],
limit=12,
filters=self._find_files_filters(route),
)
return RetrievalPlan(
profile=self._summary_profile(route),
layers=list(self._SUMMARY_LAYERS),
limit=8,
filters=self._summary_filters(route),
)
def _summary_profile(self, route: V2RouteResult) -> str:
signals = anchor_signal_types(route)
if len(signals - {V2AnchorType.FIND_FILES}) != 1:
return "docs_summary_generic"
mapping = {
V2AnchorType.API_ENDPOINT: "docs_summary_api_endpoint",
V2AnchorType.ARCHITECTURE: "docs_summary_architecture",
V2AnchorType.LOGIC_FLOW: "docs_summary_logic_flow",
V2AnchorType.DOMAIN_ENTITY: "docs_summary_domain_entity",
}
signal = next(iter(signals - {V2AnchorType.FIND_FILES}), None)
return mapping.get(signal, "docs_summary_generic")
def _general_filters(self, route: V2RouteResult) -> dict[str, object]:
return {
"prefer_path_prefixes": ["docs/architecture/", "docs/"],
"prefer_like_patterns": ["%README.md%", "%overview%"],
"target_doc_hints": list(route.anchors.target_doc_hints),
}
def _summary_filters(self, route: V2RouteResult) -> dict[str, object]:
filters: dict[str, object] = {
"prefer_path_prefixes": self._summary_prefixes(route),
"prefer_like_patterns": self._prefer_like_patterns(route),
"target_doc_hints": list(route.anchors.target_doc_hints),
}
if V2AnchorType.API_ENDPOINT in anchor_signal_types(route):
filters["path_prefixes"] = ["docs/api/", "docs/architecture/", "docs/"]
return filters
def _find_files_filters(self, route: V2RouteResult) -> dict[str, object]:
filters: dict[str, object] = {
"prefer_path_prefixes": self._find_files_prefixes(route),
"prefer_like_patterns": self._prefer_like_patterns(route),
"target_doc_hints": list(route.anchors.target_doc_hints),
}
if route.anchors.target_doc_hints:
filters["prefer_like_patterns"] = [f"%{path.split('/')[-1]}%" for path in route.anchors.target_doc_hints]
return filters
def _prefer_like_patterns(self, route: V2RouteResult) -> list[str]:
patterns: list[str] = []
for path in route.anchors.target_doc_hints:
patterns.append(f"%{path.split('/')[-1]}%")
for endpoint in route.anchors.endpoint_paths:
patterns.append(f"%{endpoint}%")
return patterns
def _find_files_prefixes(self, route: V2RouteResult) -> list[str]:
if route.anchors.target_doc_hints:
prefixes = ["/".join(path.split("/")[:-1]) + "/" for path in route.anchors.target_doc_hints]
return [prefix for prefix in prefixes if prefix]
signals = anchor_signal_types(route)
if V2AnchorType.API_ENDPOINT in signals:
return ["docs/api/", "docs/"]
if V2AnchorType.ARCHITECTURE in signals:
return ["docs/architecture/", "docs/"]
if V2AnchorType.LOGIC_FLOW in signals:
return ["docs/logic/", "docs/"]
if V2AnchorType.DOMAIN_ENTITY in signals:
return ["docs/domains/", "docs/"]
return ["docs/"]
def _summary_prefixes(self, route: V2RouteResult) -> list[str]:
signals = anchor_signal_types(route)
prefixes: list[str] = []
if V2AnchorType.API_ENDPOINT in signals:
prefixes.extend(["docs/api/", "docs/"])
if V2AnchorType.ARCHITECTURE in signals:
prefixes.extend(["docs/architecture/", "docs/"])
if V2AnchorType.LOGIC_FLOW in signals:
prefixes.extend(["docs/logic/", "docs/architecture/", "docs/"])
if V2AnchorType.DOMAIN_ENTITY in signals:
prefixes.extend(["docs/domains/", "docs/api/", "docs/architecture/"])
return list(dict.fromkeys(prefixes or ["docs/"]))
@@ -0,0 +1,114 @@
from __future__ import annotations
def normalize_doc_path(path: str | None) -> str:
value = str(path or "").strip().replace("\\", "/")
if not value:
return ""
while "//" in value:
value = value.replace("//", "/")
while value.startswith("./"):
value = value[2:]
value = value.lstrip("/")
docs_idx = value.lower().find("docs/")
if docs_idx >= 0:
value = value[docs_idx:]
elif "/" not in value and value.lower().endswith(".md"):
value = f"docs/{value}"
return value.strip()
def row_path(row: dict) -> str:
metadata = dict(row.get("metadata") or {})
raw = row.get("path") or metadata.get("source_path") or ""
return normalize_doc_path(str(raw))
def normalized_path_set(rows: list[dict]) -> set[str]:
return {path for row in rows if (path := row_path(row))}
def path_variants_for_rag_query(path: str | None) -> list[str]:
normalized = normalize_doc_path(path)
if not normalized:
return []
variants = [normalized]
if normalized.startswith("docs/"):
variants.append(normalized.removeprefix("docs/"))
else:
variants.append(f"docs/{normalized}")
basename = normalized.split("/")[-1]
if basename and basename not in variants:
variants.append(basename)
return list(dict.fromkeys(variants))
def merge_row_lists(*groups: list[dict]) -> list[dict]:
merged: list[dict] = []
seen: set[tuple[str, str, str]] = set()
for rows in groups:
for row in rows:
metadata = dict(row.get("metadata") or {})
key = (
row_path(row),
str(row.get("layer") or ""),
str(metadata.get("section_path") or ""),
)
if key in seen:
continue
seen.add(key)
merged.append(row)
return merged
class RagRowIndex:
def __init__(self, rows: list[dict]) -> None:
self._by_path: dict[str, list[dict]] = {}
self._by_name: dict[str, list[dict]] = {}
for row in rows:
normalized = row_path(row)
if not normalized:
continue
self._by_path.setdefault(normalized.lower(), []).append(row)
basename = normalized.split("/")[-1].lower()
self._by_name.setdefault(basename, []).append(row)
def lookup(self, hint: str | None) -> list[dict]:
matches: list[dict] = []
seen_ids: set[int] = set()
for variant in path_variants_for_rag_query(hint):
key = variant.lower()
for row in self._by_path.get(key, []):
row_id = id(row)
if row_id in seen_ids:
continue
seen_ids.add(row_id)
matches.append(row)
basename = normalize_doc_path(hint).split("/")[-1].lower()
for row in self._by_name.get(basename, []):
row_id = id(row)
if row_id in seen_ids:
continue
seen_ids.add(row_id)
matches.append(row)
return matches
def seed_candidates_from_target_hints(rows: list[dict], hints: list[str], index: RagRowIndex | None = None) -> list[dict]:
hints_raw = [str(hint).strip() for hint in hints if str(hint or "").strip()]
if not hints_raw or not rows:
return rows
rag_index = index or RagRowIndex(rows)
seeded = [match for hint in hints_raw for match in rag_index.lookup(hint)]
return merge_row_lists(seeded, rows)
__all__ = [
"RagRowIndex",
"merge_row_lists",
"normalize_doc_path",
"normalized_path_set",
"path_variants_for_rag_query",
"row_path",
"seed_candidates_from_target_hints",
]
@@ -0,0 +1,33 @@
"""Адаптер v2 к :class:`RagSessionRetriever` для подстановки в тестах."""
from __future__ import annotations
from app.core.rag.retrieval.session_retriever import RagSessionRetriever, RetrievalPlan
class V2RagRetrievalAdapter:
"""Обёртка над :class:`RagSessionRetriever` для подмены в тестах."""
def __init__(self, retriever: RagSessionRetriever) -> None:
self._retriever = retriever
async def fetch_rows(self, rag_session_id: str, query_text: str, plan: RetrievalPlan) -> list[dict]:
return await self._retriever.retrieve(rag_session_id, query_text, plan)
async def fetch_exact_paths(self, rag_session_id: str, *, paths: list[str], layers: list[str] | None = None) -> list[dict]:
return await self._retriever.retrieve_exact_files(rag_session_id, paths=paths, layers=layers)
async def fetch_chunks_by_path_substrings(
self,
rag_session_id: str,
*,
path_needles: list[str],
layers: list[str] | None = None,
limit: int = 200,
) -> list[dict]:
return await self._retriever.retrieve_chunks_by_path_substrings(
rag_session_id,
path_needles=path_needles,
layers=layers,
limit=limit,
)
@@ -0,0 +1,3 @@
from app.core.agent.processes.v2.workflows.docs_explain_find_files.graph import DocsExplainFindFilesGraph
__all__ = ["DocsExplainFindFilesGraph"]
@@ -0,0 +1,17 @@
from __future__ import annotations
from dataclasses import dataclass, field
from app.core.agent.processes.v2.evidence.gate import EvidenceGateDecision
from app.core.agent.processes.v2.models import RetrievedFile, V2RouteResult
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
@dataclass(slots=True)
class DocsExplainFindFilesContext:
runtime: RuntimeExecutionContext
route: V2RouteResult
rag_session_id: str
files: list[RetrievedFile] = field(default_factory=list)
gate_decision: EvidenceGateDecision | None = None
answer: str = ""
@@ -0,0 +1,16 @@
from __future__ import annotations
from app.core.agent.processes.v2.workflows.docs_explain_find_files.context import DocsExplainFindFilesContext
from app.core.agent.processes.v2.workflows.docs_explain_find_files.steps.finalize_find_files_answer_step import (
FinalizeFindFilesAnswerStep,
)
from app.core.agent.processes.v2.workflows.v2_workflow_graph import V2WorkflowGraph
class DocsExplainFindFilesGraph(V2WorkflowGraph[DocsExplainFindFilesContext]):
def __init__(self) -> None:
super().__init__(
workflow_id="v2.docs_explain.find_files",
source="workflow.v2.find_files",
steps=[FinalizeFindFilesAnswerStep()],
)
@@ -0,0 +1,25 @@
from __future__ import annotations
from app.core.agent.processes.v2.workflows.docs_explain_find_files.context import DocsExplainFindFilesContext
from app.core.agent.utils.workflow import WorkflowStep
class FinalizeFindFilesAnswerStep(WorkflowStep[DocsExplainFindFilesContext]):
step_id = "finalize_find_files_answer"
title = "Сборка списка файлов"
async def run(self, context: DocsExplainFindFilesContext) -> DocsExplainFindFilesContext:
if not context.files:
context.answer = "Не нашёл файлов документации, которые уверенно соответствуют запросу."
return context
if context.gate_decision is not None and context.gate_decision.reason == "low_confidence_shortlist":
context.answer = "\n".join(item.path for item in context.files[:4])
return context
if len(context.files) == 1:
context.answer = context.files[0].path
return context
context.answer = "\n".join(item.path for item in context.files[:4])
return context
def trace_output(self, context: DocsExplainFindFilesContext) -> dict[str, object]:
return {"answer_length": len(context.answer)}
@@ -0,0 +1,3 @@
from app.core.agent.processes.v2.workflows.docs_explain_summary.graph import DocsExplainSummaryGraph
__all__ = ["DocsExplainSummaryGraph"]
@@ -0,0 +1,20 @@
from __future__ import annotations
from dataclasses import dataclass, field
from app.core.agent.processes.v2.evidence.gate import EvidenceGateDecision
from app.core.agent.processes.v2.models import RetrievedSummary, V2RouteResult
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
@dataclass(slots=True)
class DocsExplainSummaryContext:
runtime: RuntimeExecutionContext
route: V2RouteResult
rag_session_id: str
prompt_name: str
workflow_llm_enabled: bool = True
documents: list[RetrievedSummary] = field(default_factory=list)
gate_decision: EvidenceGateDecision | None = None
prompt_input: str = ""
answer: str = ""
@@ -0,0 +1,17 @@
from __future__ import annotations
from app.core.agent.processes.v2.workflows.docs_explain_summary.context import DocsExplainSummaryContext
from app.core.agent.processes.v2.workflows.docs_explain_summary.steps.generate_summary_answer_step import (
GenerateSummaryAnswerStep,
)
from app.core.agent.processes.v2.workflows.v2_workflow_graph import V2WorkflowGraph
from app.core.agent.utils.llm import AgentLlmService
class DocsExplainSummaryGraph(V2WorkflowGraph[DocsExplainSummaryContext]):
def __init__(self, llm: AgentLlmService) -> None:
super().__init__(
workflow_id="v2.docs_explain.summary",
source="workflow.v2.summary",
steps=[GenerateSummaryAnswerStep(llm)],
)
@@ -0,0 +1,68 @@
from __future__ import annotations
import asyncio
import json
from app.core.agent.processes.v2.anchor_signals import route_anchor_summary
from app.core.agent.utils.llm import AgentLlmService
from app.core.agent.processes.v2.workflows.docs_explain_summary.context import DocsExplainSummaryContext
from app.core.agent.utils.workflow import WorkflowStep
class GenerateSummaryAnswerStep(WorkflowStep[DocsExplainSummaryContext]):
step_id = "generate_summary_answer"
title = "Сборка ответа по summary"
def __init__(self, llm: AgentLlmService) -> None:
self._llm = llm
async def run(self, context: DocsExplainSummaryContext) -> DocsExplainSummaryContext:
if context.gate_decision is not None and not context.gate_decision.passed:
context.answer = context.gate_decision.message
return context
if not context.workflow_llm_enabled:
context.answer = self._build_deterministic_answer(context)
return context
if not context.documents:
context.answer = "Не нашёл подходящих SUMMARY-блоков в документации по этому запросу."
return context
context.prompt_input = self._build_prompt_input(context)
request_id = context.runtime.request.request_id
context.answer = await asyncio.to_thread(
self._llm.generate,
context.prompt_name,
context.prompt_input,
log_context=f"agent:{request_id}",
trace=context.runtime.trace.module("workflow.v2.summary.llm"),
)
return context
def _build_prompt_input(self, context: DocsExplainSummaryContext) -> str:
blocks = [
f"Запрос пользователя:\n{context.route.user_query}",
"Сигналы запроса:\n" + json.dumps(route_anchor_summary(context.route), ensure_ascii=False, indent=2),
"Найденные SUMMARY-блоки:",
]
for index, item in enumerate(context.documents, start=1):
blocks.append(
f"{index}. path: {item.path}\n"
f"title: {item.title}\n"
f"match_reason: {item.match_reason}\n"
f"summary: {item.summary}"
)
return "\n\n".join(blocks)
def _build_deterministic_answer(self, context: DocsExplainSummaryContext) -> str:
if not context.documents:
return "Не нашёл подходящих SUMMARY-блоков в документации по этому запросу."
lines = []
primary = context.documents[0]
lines.append(primary.summary)
lines.append("")
lines.append("Файлы-источники:")
for item in context.documents:
lines.append(f"- {item.path}")
return "\n".join(lines)
def trace_output(self, context: DocsExplainSummaryContext) -> dict[str, object]:
return {"answer_length": len(context.answer)}
@@ -0,0 +1,3 @@
from app.core.agent.processes.v2.workflows.general_summary.graph import GeneralSummaryGraph
__all__ = ["GeneralSummaryGraph"]
@@ -0,0 +1,19 @@
from __future__ import annotations
from dataclasses import dataclass, field
from app.core.agent.processes.v2.evidence.gate import EvidenceGateDecision
from app.core.agent.processes.v2.models import RetrievedSummary, V2RouteResult
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
@dataclass(slots=True)
class GeneralSummaryContext:
runtime: RuntimeExecutionContext
route: V2RouteResult
prompt_name: str
workflow_llm_enabled: bool = True
documents: list[RetrievedSummary] = field(default_factory=list)
gate_decision: EvidenceGateDecision | None = None
prompt_input: str = ""
answer: str = ""
@@ -0,0 +1,17 @@
from __future__ import annotations
from app.core.agent.processes.v2.workflows.general_summary.context import GeneralSummaryContext
from app.core.agent.processes.v2.workflows.general_summary.steps.generate_general_summary_answer_step import (
GenerateGeneralSummaryAnswerStep,
)
from app.core.agent.processes.v2.workflows.v2_workflow_graph import V2WorkflowGraph
from app.core.agent.utils.llm import AgentLlmService
class GeneralSummaryGraph(V2WorkflowGraph[GeneralSummaryContext]):
def __init__(self, llm: AgentLlmService) -> None:
super().__init__(
workflow_id="v2.general_qa.summary",
source="workflow.v2.general_summary",
steps=[GenerateGeneralSummaryAnswerStep(llm)],
)
@@ -0,0 +1,57 @@
from __future__ import annotations
import asyncio
from app.core.agent.processes.v2.workflows.general_summary.context import GeneralSummaryContext
from app.core.agent.utils.llm import AgentLlmService
from app.core.agent.utils.workflow import WorkflowStep
class GenerateGeneralSummaryAnswerStep(WorkflowStep[GeneralSummaryContext]):
step_id = "generate_general_summary_answer"
title = "Общий ответ через LLM"
def __init__(self, llm: AgentLlmService) -> None:
self._llm = llm
async def run(self, context: GeneralSummaryContext) -> GeneralSummaryContext:
if context.gate_decision is not None and not context.gate_decision.passed:
context.answer = context.gate_decision.message
return context
if not context.workflow_llm_enabled:
context.answer = self._build_deterministic_answer(context)
return context
context.prompt_input = self._build_prompt_input(context)
request_id = context.runtime.request.request_id
context.answer = await asyncio.to_thread(
self._llm.generate,
context.prompt_name,
context.prompt_input,
log_context=f"agent:{request_id}",
trace=context.runtime.trace.module("workflow.v2.general_summary.llm"),
)
return context
def _build_prompt_input(self, context: GeneralSummaryContext) -> str:
blocks = [
f"Запрос пользователя:\n{context.route.user_query}",
"Опорные документы:",
]
for index, item in enumerate(context.documents, start=1):
blocks.append(
f"{index}. path: {item.path}\n"
f"title: {item.title}\n"
f"summary: {item.summary}"
)
return "\n\n".join(blocks)
def _build_deterministic_answer(self, context: GeneralSummaryContext) -> str:
if not context.documents:
return "В найденной документации нет достаточной опоры для общего summary по запросу."
return "\n".join(item.summary for item in context.documents[:2] if item.summary)
def trace_input(self, context: GeneralSummaryContext) -> dict[str, object]:
return {"query": context.route.normalized_query}
def trace_output(self, context: GeneralSummaryContext) -> dict[str, object]:
return {"answer_length": len(context.answer)}
@@ -0,0 +1,39 @@
"""Workflow-граф v2: буфер шаговых логов и один сброс в trace в конце прогона."""
from __future__ import annotations
from typing import Generic, Sequence, TypeVar
from app.core.agent.utils.workflow.context import WorkflowContext
from app.core.agent.utils.workflow.graph import WorkflowGraph
from app.core.agent.utils.workflow.step import WorkflowStep
TContext = TypeVar("TContext", bound=WorkflowContext)
class V2WorkflowGraph(WorkflowGraph[TContext]):
"""Не логирует step_started/step_completed по отдельности; сбрасывает буфер в ``workflow_trace_flushed``."""
async def run(self, context: TContext) -> TContext:
trace = context.runtime.trace.module(self._source)
trace.log("workflow_started", {"workflow_id": self._workflow_id})
steps_buffer: list[dict[str, object]] = []
for step in self._steps:
inp = step.trace_input(context)
request_id = context.runtime.request.request_id
await context.runtime.publisher.publish_status(
request_id,
self._source,
f"Шаг workflow: {step.title}.",
{"workflow_id": self._workflow_id, "step_id": step.step_id},
)
context = await step.run(context)
out = step.trace_output(context)
steps_buffer.append({"step_id": step.step_id, "title": step.title, "input": inp, "output": out})
trace.log(
"workflow_trace_flushed",
{"workflow_id": self._workflow_id, "steps": steps_buffer},
)
trace.log("workflow_completed", {"workflow_id": self._workflow_id})
return context
+13
View File
@@ -0,0 +1,13 @@
from app.core.agent.runtime.agent_runtime import AgentRuntime
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
from app.core.agent.runtime.process_registry import ProcessRegistry
from app.core.agent.runtime.process_runner import ProcessRunner
from app.core.agent.runtime.publisher import RuntimeEventPublisher
__all__ = [
"AgentRuntime",
"ProcessRegistry",
"ProcessRunner",
"RuntimeEventPublisher",
"RuntimeExecutionContext",
]
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
from datetime import datetime, timezone
from app.core.api.application.session_service import SessionService
from app.core.api.domain.models.agent_request import AgentRequest
from app.core.api.domain.models.agent_session import AgentSession
from app.core.api.infrastructure.stores.in_memory_request_store import InMemoryRequestStore
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
from app.core.agent.runtime.process_registry import ProcessRegistry
from app.core.agent.runtime.process_runner import ProcessRunner
from app.core.agent.runtime.publisher import RuntimeEventPublisher
from app.infra.exceptions import AppError
from app.infra.observability.module_trace import RequestTraceContext
from app.infra.observability.request_trace_logger import RequestTraceLogger
from app.schemas.common import ErrorPayload, ModuleName
from app.schemas.orchestration import RequestExecutionStatus
class AgentRuntime:
def __init__(
self,
request_store: InMemoryRequestStore,
sessions: SessionService,
process_registry: ProcessRegistry,
process_runner: ProcessRunner,
publisher: RuntimeEventPublisher,
trace_logger: RequestTraceLogger,
) -> None:
self._request_store = request_store
self._sessions = sessions
self._process_registry = process_registry
self._process_runner = process_runner
self._publisher = publisher
self._trace_logger = trace_logger
async def run(self, request: AgentRequest, session: AgentSession) -> None:
try:
process = self._resolve_process(request.process_version)
self._start_request(request, session)
context = RuntimeExecutionContext(
request=request,
session=session,
publisher=self._publisher,
trace=RequestTraceContext(request_id=request.request_id, logger=self._trace_logger),
)
await self._announce_start(request.request_id, process.version)
result = await self._process_runner.run(context, process)
request.answer = result.answer
await self._publish_result(request)
self._complete_request(request, session)
except Exception as exc:
await self._fail_request(request, exc)
def _resolve_process(self, version: str):
process = self._process_registry.get(version)
if process is None:
raise AppError("process_not_found", f"Unsupported process version: {version}", ModuleName.AGENT)
return process
def _start_request(self, request: AgentRequest, session: AgentSession) -> None:
request.status = RequestExecutionStatus.RUNNING
self._request_store.save(request)
self._trace_logger.start_request(request, session)
async def _announce_start(self, request_id: str, process_version: str) -> None:
await self._publisher.publish_status(request_id, "runtime", "Запрос принят и поставлен в обработку.")
await self._publisher.publish_status(
request_id,
"runtime",
f"Запускаю процесс {process_version}.",
{"process_version": process_version},
)
async def _publish_result(self, request: AgentRequest) -> None:
await self._publisher.publish_user(request.request_id, "agent", request.answer or "")
await self._publisher.publish_status(request.request_id, "runtime", "Обработка запроса завершена.")
def _complete_request(self, request: AgentRequest, session: AgentSession) -> None:
session.append_turn(user_message=request.message, assistant_message=request.answer or "")
self._sessions.save(session)
request.status = RequestExecutionStatus.DONE
request.completed_at = datetime.now(timezone.utc)
self._request_store.save(request)
self._trace_logger.complete_request(request)
async def _fail_request(self, request: AgentRequest, exc: Exception) -> None:
request.status = RequestExecutionStatus.ERROR
request.completed_at = datetime.now(timezone.utc)
request.error = self._build_error_payload(exc)
self._request_store.save(request)
self._trace_logger.fail_request(request)
await self._publisher.publish_status(
request.request_id,
"runtime",
"Во время обработки запроса произошла ошибка.",
{"code": request.error.code},
)
def _build_error_payload(self, exc: Exception) -> ErrorPayload:
if isinstance(exc, AppError):
return ErrorPayload(code=exc.code, desc=exc.desc, module=exc.module)
return ErrorPayload(
code="api_runtime_error",
desc="Agent request failed unexpectedly.",
module=ModuleName.AGENT,
)
@@ -0,0 +1,20 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from app.core.api.domain.models.agent_request import AgentRequest
from app.core.api.domain.models.agent_session import AgentSession
from app.infra.observability.module_trace import RequestTraceContext
if TYPE_CHECKING:
from app.core.agent.runtime.publisher import RuntimeEventPublisher
@dataclass(slots=True)
class RuntimeExecutionContext:
request: AgentRequest
session: AgentSession
publisher: "RuntimeEventPublisher"
trace: RequestTraceContext
state: dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,13 @@
from __future__ import annotations
from collections.abc import Iterable
from app.core.agent.processes.base import AgentProcess
class ProcessRegistry:
def __init__(self, processes: Iterable[AgentProcess]) -> None:
self._items = {process.version: process for process in processes}
def get(self, version: str) -> AgentProcess | None:
return self._items.get(version)
@@ -0,0 +1,9 @@
from __future__ import annotations
from app.core.agent.processes.base import AgentProcess, ProcessResult
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
class ProcessRunner:
async def run(self, context: RuntimeExecutionContext, process: AgentProcess) -> ProcessResult:
return await process.run(context)
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from app.core.api.domain.events.client_event import ClientEventRecord
from app.core.api.infrastructure.streaming.sse_event_channel import SseEventChannel
from app.infra.observability.request_trace_logger import RequestTraceLogger
from app.schemas.client_events import ClientEventType
class RuntimeEventPublisher:
def __init__(self, channel: SseEventChannel, trace_logger: RequestTraceLogger) -> None:
self._channel = channel
self._trace_logger = trace_logger
async def publish_status(self, request_id: str, source: str, text: str, payload: dict | None = None) -> None:
await self._publish(request_id, ClientEventType.STATUS, source, text, payload)
async def publish_user(self, request_id: str, source: str, text: str, payload: dict | None = None) -> None:
await self._publish(request_id, ClientEventType.USER, source, text, payload)
async def _publish(
self,
request_id: str,
event_type: ClientEventType,
source: str,
text: str,
payload: dict | None = None,
) -> None:
event = ClientEventRecord(
request_id=request_id,
type=event_type,
source=source,
text=text,
payload=payload or {},
)
self._trace_logger.log_event(event)
await self._channel.publish(event)
+3
View File
@@ -0,0 +1,3 @@
from app.core.agent.utils.llm import AgentLlmService, PromptLoader
__all__ = ["AgentLlmService", "PromptLoader"]
+4
View File
@@ -0,0 +1,4 @@
from app.core.agent.utils.llm.prompt_loader import PromptLoader
from app.core.agent.utils.llm.service import AgentLlmService
__all__ = ["AgentLlmService", "PromptLoader"]
@@ -0,0 +1,43 @@
from __future__ import annotations
import os
from collections.abc import Iterable
from pathlib import Path
import yaml
class PromptLoader:
def __init__(self, prompts_path: Path | Iterable[Path] | None = None) -> None:
self._paths = self._resolve_paths(prompts_path)
self._prompts = self._load_prompts()
def load(self, name: str) -> str:
return str(self._prompts.get(name, "") or "").strip()
def _load_prompts(self) -> dict[str, str]:
merged: dict[str, str] = {}
for path in self._paths:
if not path.is_file():
continue
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(payload, dict):
continue
namespace = str(payload.get("namespace") or "").strip()
prompts = payload.get("prompts", payload)
if not isinstance(prompts, dict):
continue
for key, value in prompts.items():
prompt_name = f"{namespace}.{key}" if namespace else str(key)
merged[prompt_name] = str(value or "")
return merged
def _resolve_paths(self, prompts_path: Path | Iterable[Path] | None) -> tuple[Path, ...]:
if prompts_path is None:
base = Path(__file__).resolve().parent / "prompts.yml"
env_override = os.getenv("AGENT_PROMPTS_DIR", "").strip()
raw_path = Path(env_override) if env_override else base
return (raw_path / "prompts.yml" if raw_path.is_dir() else raw_path,)
if isinstance(prompts_path, Path):
return (prompts_path,)
return tuple(Path(item) for item in prompts_path)
@@ -1,8 +1,10 @@
from __future__ import annotations
import logging
from app.modules.agent.observability.module_trace import ModuleTrace
from app.modules.agent.llm.prompt_loader import PromptLoader
from app.modules.shared.gigachat.client import GigaChatClient
from app.core.agent.utils.llm.prompt_loader import PromptLoader
from app.core.shared.gigachat.client import GigaChatClient
from app.infra.observability.module_trace import ModuleTrace
LOGGER = logging.getLogger(__name__)
@@ -0,0 +1 @@
"""Shared trace helpers will live here."""
@@ -0,0 +1,9 @@
from app.core.agent.utils.workflow.context import WorkflowContext
from app.core.agent.utils.workflow.graph import WorkflowGraph
from app.core.agent.utils.workflow.step import WorkflowStep
__all__ = [
"WorkflowContext",
"WorkflowGraph",
"WorkflowStep",
]
@@ -0,0 +1,9 @@
from __future__ import annotations
from typing import Protocol
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
class WorkflowContext(Protocol):
runtime: RuntimeExecutionContext
@@ -0,0 +1,44 @@
from __future__ import annotations
from typing import Generic, Sequence, TypeVar
from app.core.agent.utils.workflow.context import WorkflowContext
from app.core.agent.utils.workflow.step import WorkflowStep
TContext = TypeVar("TContext", bound=WorkflowContext)
class WorkflowGraph(Generic[TContext]):
def __init__(self, workflow_id: str, source: str, steps: Sequence[WorkflowStep[TContext]]) -> None:
self._workflow_id = workflow_id
self._source = source
self._steps = tuple(steps)
async def run(self, context: TContext) -> TContext:
trace = context.runtime.trace.module(self._source)
trace.log("workflow_started", {"workflow_id": self._workflow_id})
for step in self._steps:
context = await self._run_step(context, step)
trace.log("workflow_completed", {"workflow_id": self._workflow_id})
return context
async def _run_step(self, context: TContext, step: WorkflowStep[TContext]) -> TContext:
request_id = context.runtime.request.request_id
trace = context.runtime.trace.module(self._source)
trace.log(
"step_started",
{"workflow_id": self._workflow_id, "step_id": step.step_id, "input": step.trace_input(context)},
)
await context.runtime.publisher.publish_status(
request_id,
self._source,
f"Шаг workflow: {step.title}.",
{"workflow_id": self._workflow_id, "step_id": step.step_id},
)
context = await step.run(context)
trace.log(
"step_completed",
{"workflow_id": self._workflow_id, "step_id": step.step_id, "output": step.trace_output(context)},
)
return context
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Generic, TypeVar
TContext = TypeVar("TContext")
class WorkflowStep(ABC, Generic[TContext]):
step_id = ""
title = ""
@abstractmethod
async def run(self, context: TContext) -> TContext:
raise NotImplementedError
def trace_input(self, context: TContext) -> dict[str, Any]:
return {}
def trace_output(self, context: TContext) -> dict[str, Any]:
return {}
@@ -2,11 +2,11 @@ from __future__ import annotations
import asyncio
from app.modules.api.domain.models.agent_request import AgentRequest
from app.modules.api.infrastructure.ids.request_id_factory import RequestIdFactory
from app.modules.api.infrastructure.stores.in_memory_request_store import InMemoryRequestStore
from app.modules.api.application.session_service import SessionService
from app.modules.agent.orchestration.facade import OrchestrationFacade
from app.core.api.domain.models.agent_request import AgentRequest
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.application.session_service import SessionService
from app.core.agent.runtime import AgentRuntime
class RequestService:
@@ -15,12 +15,12 @@ class RequestService:
request_store: InMemoryRequestStore,
request_ids: RequestIdFactory,
sessions: SessionService,
orchestration: OrchestrationFacade,
runtime: AgentRuntime,
) -> None:
self._request_store = request_store
self._request_ids = request_ids
self._sessions = sessions
self._orchestration = orchestration
self._runtime = runtime
async def create(self, session_id: str, message: str, process_version: str) -> AgentRequest:
session = self._sessions.get(session_id)
@@ -31,7 +31,7 @@ class RequestService:
process_version=process_version,
)
self._request_store.save(request)
asyncio.create_task(self._orchestration.run(request, session))
asyncio.create_task(self._runtime.run(request, session))
return request
def get(self, request_id: str) -> AgentRequest | None:
@@ -0,0 +1,25 @@
from __future__ import annotations
from dataclasses import dataclass
from app.core.api.application.session_service import SessionService
from app.core.api.domain.models.agent_session import AgentSession
from app.core.rag.indexing import IndexJob
from app.core.rag.module import RagModule
@dataclass(slots=True)
class BootstrappedAgentSession:
session: AgentSession
index_job: IndexJob
class SessionBootstrapService:
def __init__(self, sessions: SessionService, rag: RagModule) -> None:
self._sessions = sessions
self._rag = rag
async def create(self, project_id: str, files: list[dict]) -> BootstrappedAgentSession:
rag_session, index_job = await self._rag.create_session(project_id=project_id, files=files)
session = self._sessions.create(rag_session_id=rag_session.rag_session_id)
return BootstrappedAgentSession(session=session, index_job=index_job)
@@ -0,0 +1,26 @@
from __future__ import annotations
from app.infra.exceptions import AppError
from app.core.api.domain.models.agent_session import AgentSession
from app.core.api.infrastructure.ids.session_id_factory import SessionIdFactory
from app.core.api.infrastructure.stores.in_memory_session_store import InMemorySessionStore
from app.schemas.common import ModuleName
class SessionService:
def __init__(self, store: InMemorySessionStore, ids: SessionIdFactory) -> None:
self._store = store
self._ids = ids
def create(self, rag_session_id: str | None = None) -> AgentSession:
session = AgentSession.create(self._ids.create(), rag_session_id=rag_session_id)
return self._store.save(session)
def get(self, session_id: str) -> AgentSession:
session = self._store.get(session_id)
if session is None:
raise AppError("session_not_found", f"Agent session not found: {session_id}", ModuleName.BACKEND)
return session
def save(self, session: AgentSession) -> AgentSession:
return self._store.save(session)
@@ -1,8 +1,8 @@
from __future__ import annotations
from app.core.exceptions import AppError
from app.modules.api.infrastructure.streaming.sse_encoder import SseEncoder
from app.modules.api.infrastructure.streaming.sse_event_channel import SseEventChannel
from app.infra.exceptions import AppError
from app.core.api.infrastructure.streaming.sse_encoder import SseEncoder
from app.core.api.infrastructure.streaming.sse_event_channel import SseEventChannel
from app.schemas.common import ModuleName
@@ -0,0 +1,33 @@
from __future__ import annotations
from app.core.api.infrastructure.streaming.sse_response_builder import build_sse_response
from app.core.rag.module import RagModule
from app.core.shared.messaging import EventBus
from app.schemas.rag_sessions import RagSessionJobResponse
class RagPublicController:
def __init__(self, rag: RagModule) -> None:
self._rag = rag
def get_job(self, rag_session_id: str, index_job_id: str) -> RagSessionJobResponse:
job = self._rag.get_session_job(rag_session_id, index_job_id)
return RagSessionJobResponse(
rag_session_id=rag_session_id,
index_job_id=job.index_job_id,
status=job.status,
indexed_files=job.indexed_files,
failed_files=job.failed_files,
cache_hit_files=job.cache_hit_files,
cache_miss_files=job.cache_miss_files,
error=job.error.model_dump(mode="json") if job.error else None,
)
async def stream_job_events(self, rag_session_id: str, index_job_id: str):
channel_id, queue = await self._rag.subscribe_session_job_events(rag_session_id, index_job_id)
return build_sse_response(
queue,
encoder=EventBus.as_sse,
unsubscribe=lambda: self._rag.unsubscribe_job_events(channel_id, queue),
stop_on_event="terminal",
)
@@ -1,7 +1,7 @@
from __future__ import annotations
from app.core.exceptions import AppError
from app.modules.api.application.request_service import RequestService
from app.infra.exceptions import AppError
from app.core.api.application.request_service import RequestService
from app.schemas.agent_api import AgentRequestCreateRequest, AgentRequestQueuedResponse, AgentRequestStateResponse
from app.schemas.common import ModuleName
@@ -0,0 +1,23 @@
from __future__ import annotations
from app.core.api.application.session_bootstrap_service import SessionBootstrapService
from app.schemas.agent_api import CreateAgentSessionRequest, CreateAgentSessionResponse
class SessionController:
def __init__(self, service: SessionBootstrapService) -> None:
self._service = service
async def create_session(self, request: CreateAgentSessionRequest) -> CreateAgentSessionResponse:
result = await self._service.create(
project_id=request.project_id,
files=[item.model_dump() for item in request.files],
)
session = result.session
return CreateAgentSessionResponse(
session_id=session.session_id,
rag_session_id=session.active_rag_session_id or "",
index_job_id=result.index_job.index_job_id,
status=result.index_job.status,
created_at=session.created_at,
)
@@ -0,0 +1,17 @@
from __future__ import annotations
from app.core.api.infrastructure.streaming.sse_response_builder import build_sse_response
from app.core.api.application.stream_service import StreamService
class StreamController:
def __init__(self, service: StreamService) -> None:
self._service = service
async def stream(self, request_id: str):
queue = await self._service.subscribe(request_id)
return build_sse_response(
queue,
encoder=self._service.encode,
unsubscribe=lambda: self._service.unsubscribe(request_id, queue),
)
@@ -0,0 +1,35 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from app.core.api.domain.models.agent_session_message import AgentSessionMessage
@dataclass(slots=True)
class AgentSession:
session_id: str
active_rag_session_id: str | None
created_at: datetime
updated_at: datetime
messages: list[AgentSessionMessage] = field(default_factory=list)
@classmethod
def create(cls, session_id: str, rag_session_id: str | None = None) -> "AgentSession":
now = datetime.now(timezone.utc)
return cls(
session_id=session_id,
active_rag_session_id=rag_session_id,
created_at=now,
updated_at=now,
)
def append_turn(self, user_message: str, assistant_message: str, route_result=None) -> None:
self._append_message("user", user_message)
self._append_message("assistant", assistant_message)
self.updated_at = datetime.now(timezone.utc)
def _append_message(self, role: str, text: str) -> None:
value = text.strip()
if value:
self.messages.append(AgentSessionMessage.create(role, value))
@@ -0,0 +1,19 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal
SessionMessageRole = Literal["user", "assistant"]
@dataclass(slots=True)
class AgentSessionMessage:
role: SessionMessageRole
text: str
created_at: datetime
@classmethod
def create(cls, role: SessionMessageRole, text: str) -> "AgentSessionMessage":
return cls(role=role, text=text, created_at=datetime.now(timezone.utc))
@@ -2,7 +2,7 @@ from __future__ import annotations
from threading import Lock
from app.modules.api.domain.models.agent_request import AgentRequest
from app.core.api.domain.models.agent_request import AgentRequest
class InMemoryRequestStore:
@@ -2,7 +2,7 @@ from __future__ import annotations
from threading import Lock
from app.modules.api.domain.models.agent_session import AgentSession
from app.core.api.domain.models.agent_session import AgentSession
class InMemorySessionStore:
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections import defaultdict
from app.modules.api.domain.events.client_event import ClientEventRecord
from app.core.api.domain.events.client_event import ClientEventRecord
class ReplayBuffer:
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
from app.modules.api.domain.events.client_event import ClientEventRecord
from app.core.api.domain.events.client_event import ClientEventRecord
class SseEncoder:
@@ -3,8 +3,8 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from app.modules.api.domain.events.client_event import ClientEventRecord
from app.modules.api.infrastructure.streaming.replay_buffer import ReplayBuffer
from app.core.api.domain.events.client_event import ClientEventRecord
from app.core.api.infrastructure.streaming.replay_buffer import ReplayBuffer
class SseEventChannel:
@@ -0,0 +1,39 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from fastapi.responses import StreamingResponse
def build_sse_response(
queue,
*,
encoder: Callable[[object], str],
unsubscribe: Callable[[], Awaitable[None]],
heartbeat_seconds: int = 10,
stop_on_event: str | None = None,
) -> StreamingResponse:
async def event_stream():
import asyncio
try:
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=heartbeat_seconds)
yield encoder(event)
if stop_on_event and getattr(event, "name", None) == stop_on_event:
break
except asyncio.TimeoutError:
yield ": keepalive\n\n"
finally:
await unsubscribe()
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from fastapi import APIRouter
from app.core.api.application.session_bootstrap_service import SessionBootstrapService
from app.core.api.application.request_service import RequestService
from app.core.api.application.stream_service import StreamService
from app.core.api.controllers.request_controller import RequestController
from app.core.api.controllers.rag_public_controller import RagPublicController
from app.core.api.controllers.session_controller import SessionController
from app.core.api.controllers.stream_controller import StreamController
from app.core.api.public_router import build_agent_public_router
from app.core.api.rag_public_router import build_rag_public_router
from app.core.rag.module import RagModule
class ApiModule:
def __init__(
self,
session_bootstrap: SessionBootstrapService,
requests: RequestService,
streams: StreamService,
rag: RagModule,
) -> None:
self._sessions = SessionController(session_bootstrap)
self._requests = RequestController(requests)
self._streams = StreamController(streams)
self._rag_public = RagPublicController(rag)
def public_router(self) -> APIRouter:
router = APIRouter()
router.include_router(
build_agent_public_router(
sessions=self._sessions,
requests=self._requests,
streams=self._streams,
)
)
router.include_router(build_rag_public_router(self._rag_public))
return router
@@ -2,21 +2,19 @@ from __future__ import annotations
from fastapi import APIRouter
from app.modules.api.controllers.request_controller import RequestController
from app.modules.api.controllers.session_controller import SessionController
from app.modules.api.controllers.stream_controller import StreamController
from app.core.api.controllers.request_controller import RequestController
from app.core.api.controllers.session_controller import SessionController
from app.core.api.controllers.stream_controller import StreamController
from app.schemas.agent_api import (
AgentRequestCreateRequest,
AgentRequestQueuedResponse,
AgentRequestStateResponse,
BindRagSessionRequest,
BindRagSessionResponse,
CreateAgentSessionRequest,
CreateAgentSessionResponse,
ResetAgentSessionResponse,
)
def build_public_router(
def build_agent_public_router(
sessions: SessionController,
requests: RequestController,
streams: StreamController,
@@ -24,16 +22,8 @@ def build_public_router(
router = APIRouter(tags=["agent-api"])
@router.post("/api/agent/sessions", response_model=CreateAgentSessionResponse)
async def create_session() -> CreateAgentSessionResponse:
return sessions.create_session()
@router.post("/api/agent/sessions/{session_id}/rag", response_model=BindRagSessionResponse)
async def bind_rag_session(session_id: str, request: BindRagSessionRequest) -> BindRagSessionResponse:
return sessions.bind_rag_session(session_id, request)
@router.post("/api/agent/sessions/{session_id}/reset", response_model=ResetAgentSessionResponse)
async def reset_session(session_id: str) -> ResetAgentSessionResponse:
return sessions.reset_session(session_id)
async def create_session(request: CreateAgentSessionRequest) -> CreateAgentSessionResponse:
return await sessions.create_session(request)
@router.post("/api/agent/requests", response_model=AgentRequestQueuedResponse)
async def create_request(request: AgentRequestCreateRequest) -> AgentRequestQueuedResponse:
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from fastapi import APIRouter
from app.core.api.controllers.rag_public_controller import RagPublicController
from app.schemas.rag_sessions import RagSessionJobResponse
def build_rag_public_router(controller: RagPublicController) -> APIRouter:
router = APIRouter(tags=["rag"])
@router.get("/api/rag/sessions/{rag_session_id}/jobs/{index_job_id}", response_model=RagSessionJobResponse)
async def rag_session_job(rag_session_id: str, index_job_id: str) -> RagSessionJobResponse:
return controller.get_job(rag_session_id, index_job_id)
@router.get("/api/rag/sessions/{rag_session_id}/jobs/{index_job_id}/events")
async def rag_session_job_events(rag_session_id: str, index_job_id: str):
return await controller.stream_job_events(rag_session_id, index_job_id)
return router
+127
View File
@@ -0,0 +1,127 @@
import logging
from pathlib import Path
from app.core.agent.processes import V1Process, V2Process
from app.core.agent.processes.v2 import V2IntentRouter
from app.core.agent.processes.v2.evidence.assembler import DocsEvidenceAssembler
from app.core.agent.processes.v2.retrieval.policy_resolver import V2RetrievalPolicyResolver
from app.core.agent.processes.v2.retrieval.v2_rag_adapter import V2RagRetrievalAdapter
from app.core.rag.retrieval.session_retriever import RagSessionRetriever
from app.core.agent.runtime import AgentRuntime, ProcessRegistry, ProcessRunner, RuntimeEventPublisher
from app.core.agent.utils.llm import AgentLlmService, PromptLoader
from app.core.api.module import ApiModule
from app.core.api.application.session_bootstrap_service import SessionBootstrapService
from app.core.api.application.request_service import RequestService
from app.core.api.application.session_service import SessionService
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.ids.session_id_factory import SessionIdFactory
from app.core.api.infrastructure.stores.in_memory_request_store import InMemoryRequestStore
from app.core.api.infrastructure.stores.in_memory_session_store import InMemorySessionStore
from app.core.api.infrastructure.streaming.sse_event_channel import SseEventChannel
from app.core.rag.module import RagModule
from app.core.rag.embedding.gigachat_embedder import GigaChatEmbedder
from app.core.rag.persistence import RagRepository
from app.core.shared.database import DatabaseReadiness, bootstrap_database
from app.core.shared.messaging import EventBus
from app.core.shared.story_context import StoryContextSchemaRepository
from app.infra.observability import RequestTraceLogger
from app.core.shared.resilience import RetryExecutor
class ModularApplication:
def __init__(self) -> None:
self.database_readiness = DatabaseReadiness()
self.events = EventBus()
self.retry = RetryExecutor()
self.rag_repository = RagRepository()
self.story_context_schema_repository = StoryContextSchemaRepository()
self.rag = RagModule(
event_bus=self.events,
retry=self.retry,
repository=self.rag_repository,
ensure_ready=self.database_readiness.require_ready,
)
from app.core.shared.gigachat.client import GigaChatClient
from app.core.shared.gigachat.settings import GigaChatSettings
from app.core.shared.gigachat.token_provider import GigaChatTokenProvider
_giga_settings = GigaChatSettings.from_env()
_giga_client = GigaChatClient(_giga_settings, GigaChatTokenProvider(_giga_settings))
_v1_prompt_loader = PromptLoader(
Path(__file__).resolve().parent / "agent/processes/v1/workflow/flow_main/prompts.yml"
)
_v2_prompt_loader = PromptLoader(
[
Path(__file__).resolve().parent / "agent/processes/v2/prompts.yml",
Path(__file__).resolve().parent / "agent/processes/v2/general_prompts.yml",
Path(__file__).resolve().parent / "agent/processes/v2/intent_router/routers/prompts.yml",
]
)
self._v1_llm = AgentLlmService(client=_giga_client, prompts=_v1_prompt_loader)
self._v2_llm = AgentLlmService(client=_giga_client, prompts=_v2_prompt_loader)
_v2_embedder = GigaChatEmbedder(_giga_client)
_v2_rag_retriever = RagSessionRetriever(repository=self.rag_repository, embedder=_v2_embedder)
_v2_rag_adapter = V2RagRetrievalAdapter(_v2_rag_retriever)
_v2_evidence = DocsEvidenceAssembler()
_v2_policy = V2RetrievalPolicyResolver()
self.agent_sessions = InMemorySessionStore()
self.agent_requests = InMemoryRequestStore()
self.agent_events = SseEventChannel()
self.agent_trace_logger = RequestTraceLogger(Path("runtime_traces/agent_requests"))
_publisher = RuntimeEventPublisher(self.agent_events, self.agent_trace_logger)
_session_service = SessionService(
store=self.agent_sessions,
ids=SessionIdFactory(),
)
_session_bootstrap = SessionBootstrapService(_session_service, self.rag)
_process_registry = ProcessRegistry(
[
V1Process(self._v1_llm),
V2Process(
self._v2_llm,
policy_resolver=_v2_policy,
rag_adapter=_v2_rag_adapter,
evidence_assembler=_v2_evidence,
router=V2IntentRouter(llm=self._v2_llm),
workflow_llm_enabled=True,
),
]
)
_runtime = AgentRuntime(
request_store=self.agent_requests,
sessions=_session_service,
process_registry=_process_registry,
process_runner=ProcessRunner(),
publisher=_publisher,
trace_logger=self.agent_trace_logger,
)
_request_service = RequestService(
request_store=self.agent_requests,
request_ids=RequestIdFactory(),
sessions=_session_service,
runtime=_runtime,
)
self.api = ApiModule(
session_bootstrap=_session_bootstrap,
requests=_request_service,
streams=StreamService(self.agent_events, request_exists=lambda request_id: self.agent_requests.get(request_id) is not None),
rag=self.rag,
)
def startup(self) -> None:
try:
bootstrap_database(
self.rag_repository,
self.story_context_schema_repository,
)
except Exception as exc:
logging.exception("Database bootstrap failed. Starting backend in degraded mode.")
self.database_readiness.mark_unavailable(exc)
return
self.database_readiness.mark_ready()
def health_payload(self) -> dict[str, str]:
return self.database_readiness.health_payload()
@@ -348,4 +348,4 @@ sequenceDiagram
- В первой итерации реализованы `DOCS D1-D4`.
- В первой итерации реализованы `CODE C0-C3`.
- `C4-C6` зафиксированы в контракте и зарезервированы под следующие этапы.
- Текущие `rag_session` и `rag_repo` работают как facade/adapter поверх нового пакета `rag`.
- Текущий `rag_session` работает как facade поверх нового пакета `rag`.
@@ -1,4 +1,4 @@
from app.modules.rag.contracts import (
from app.core.rag.contracts import (
DocKind,
EvidenceLink,
EvidenceType,

Some files were not shown because too many files have changed in this diff Show More