This commit is contained in:
2026-03-27 15:51:10 +03:00
parent 15586f9a8c
commit 51378c5d66
1234 changed files with 95644 additions and 543076 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ def create_app() -> FastAPI:
allow_headers=["*"],
)
app.include_router(modules.chat.public_router())
app.include_router(modules.agent_api.public_router())
app.include_router(modules.rag.public_router())
app.include_router(modules.rag.internal_router())
app.include_router(modules.rag_repo.internal_router())
@@ -0,0 +1,64 @@
from __future__ import annotations
from app.modules.agent.intent_router_v2.analysis.docs_query_signals import DocsQuerySignals
from app.modules.agent.intent_router_v2.analysis.docs_sub_intent_detector import DocsSubIntentDetector
class DocsAmbiguityDetector:
_ALLOWED = {
"SYSTEM_FLOW_EXPLAIN",
"COMPONENT_EXPLAIN",
"API_METHOD_EXPLAIN",
"ENTITY_EXPLAIN",
"RELATED_DOCS_EXPLAIN",
"GENERIC_QA",
}
def __init__(
self,
docs_signals: DocsQuerySignals | None = None,
detector: DocsSubIntentDetector | None = None,
) -> None:
self._docs_signals = docs_signals or DocsQuerySignals()
self._detector = detector or DocsSubIntentDetector()
self._natural_entity_markers = ("runtime health", "статус воркера", "состояние runtime", "состояние воркера")
self._natural_flow_markers = ("health check runtime", "проверка состояния", "как работает health check", "как происходит проверка")
def detect(self, query: str, *, intent: str, sub_intent: str) -> dict[str, object]:
if intent not in {"DOCUMENTATION_EXPLAIN", "GENERAL_QA"}:
return self._result(False, "", sub_intent, [])
candidates = [name for name, _ in self._detector.rank_candidates(query, intent="DOCUMENTATION_EXPLAIN") if name in self._ALLOWED]
if not candidates:
return self._result(False, "", sub_intent, [])
top_scores = self._detector.rank_candidates(query, intent="DOCUMENTATION_EXPLAIN")
score_map = {name: score for name, score in top_scores}
selected_score = int(score_map.get(sub_intent, 0))
second_score = int(next((score for name, score in top_scores if name != sub_intent), 0))
has_strong_anchor = bool(self._docs_signals.query_anchor_candidates(query)) or self._docs_signals.has_component_like_token(query)
if sub_intent == "GENERIC_QA" and has_strong_anchor:
return self._result(True, "general_vs_anchor_conflict", sub_intent, candidates[:4])
if sub_intent != "GENERIC_QA" and self._looks_overview_like(query) and not has_strong_anchor:
return self._result(True, "overview_vs_explain_conflict", sub_intent, candidates[:4])
if not has_strong_anchor and self._looks_natural_boundary(query):
return self._result(True, "natural_language_boundary", sub_intent, candidates[:4])
if selected_score and second_score and selected_score - second_score <= 1 and second_score >= 2:
return self._result(True, "small_score_gap", sub_intent, candidates[:4])
if not has_strong_anchor and selected_score <= 3 and second_score >= 1:
return self._result(True, "weak_deterministic_signal", sub_intent, candidates[:4])
return self._result(False, "", sub_intent, candidates[:4])
def _looks_overview_like(self, query: str) -> bool:
text = " ".join((query or "").lower().split())
return any(marker in text for marker in ("что есть в документации", "какая структура документации", "что описано", "обзор документации", "с чего начать"))
def _looks_natural_boundary(self, query: str) -> bool:
text = " ".join((query or "").lower().split())
return any(marker in text for marker in (*self._natural_entity_markers, *self._natural_flow_markers))
def _result(self, is_ambiguous: bool, reason: str, selected: str, candidates: list[str]) -> dict[str, object]:
return {
"is_ambiguous": is_ambiguous,
"ambiguity_reason": reason,
"deterministic_candidates": list(candidates),
"deterministic_primary_candidate": selected,
}
@@ -0,0 +1,75 @@
from __future__ import annotations
import re
class DocsQuerySignals:
_ENDPOINT_RE = re.compile(r"(?P<value>/(?:[a-z0-9_-]+|{[a-z0-9_]+})(?:/(?:[a-z0-9_-]+|{[a-z0-9_]+}))*)", re.IGNORECASE)
_ENTITY_RE = re.compile(r"(?:entity|сущност[ьи]|модель|объект)\s+(?P<value>[A-Za-z][A-Za-z0-9_-]+(?:\s+[A-Za-z][A-Za-z0-9_-]+)?)", re.IGNORECASE)
_COMPONENT_RE = re.compile(r"(?:component|компонент|service|module|модул[ья])\s+(?P<value>[A-Za-z][A-Za-z0-9_-]+)", re.IGNORECASE)
_WORKFLOW_RE = re.compile(r"(?:workflow|сценарий|процесс|overview)\s+(?P<value>[A-Za-zА-Яа-я0-9_-]+)", re.IGNORECASE)
_DOC_RE = re.compile(r"(?:document|документ|documentation|документац[ияи])\s+(?P<value>[A-Za-zА-Яа-я0-9_.-]+)", re.IGNORECASE)
_CAMEL_RE = re.compile(r"\b(?P<value>[A-Z][A-Za-z0-9]+(?:Manager|Worker|Channel|Service|Module|Status|Health)|[A-Z][a-z0-9]+(?:[A-Z][A-Za-z0-9]+)+)\b")
_QUOTED_RE = re.compile(r"[\"'«“](?P<value>[^\"'»”]{2,})[\"'»”]")
_MULTIWORD_RE = re.compile(r"\b(runtime health|worker status|control plane)\b", re.IGNORECASE)
def detect_anchor(self, raw: str) -> tuple[str, str | None]:
text = raw or ""
anchor_patterns = [
("entity", self._ENTITY_RE),
("component", self._COMPONENT_RE),
("workflow", self._WORKFLOW_RE),
("document", self._DOC_RE),
]
if not any(ext in text.lower() for ext in (".py", ".ts", ".js", ".java", ".go", ".rb")):
anchor_patterns.insert(0, ("endpoint", self._ENDPOINT_RE))
for anchor_type, pattern in anchor_patterns:
match = pattern.search(text)
if match:
return anchor_type, str(match.group("value")).strip()
if any(marker in text.lower() for marker in ("api", "endpoint", "topic", "тема")):
return "topic", None
return "none", None
def has_docs_anchor(self, raw: str) -> bool:
anchor_type, _ = self.detect_anchor(raw)
return anchor_type != "none"
def query_entity_candidates(self, raw: str) -> list[str]:
text = raw or ""
values: list[str] = []
for match in self._ENTITY_RE.finditer(text):
values.append(match.group("value").strip())
for match in self._CAMEL_RE.finditer(text):
values.append(match.group("value").strip())
for match in self._MULTIWORD_RE.finditer(text):
values.append(match.group(1).strip())
return self._dedupe(values)
def query_anchor_candidates(self, raw: str) -> list[str]:
text = raw or ""
values: list[str] = []
for match in self._ENDPOINT_RE.finditer(text):
values.append(match.group("value").strip())
for match in self._CAMEL_RE.finditer(text):
values.append(match.group("value").strip())
for match in self._QUOTED_RE.finditer(text):
values.append(match.group("value").strip())
return self._dedupe(values)
def has_component_like_token(self, raw: str) -> bool:
return bool(self._CAMEL_RE.search(raw or ""))
def _dedupe(self, values: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
normalized = value.strip()
if not normalized:
continue
key = normalized.lower()
if key in seen:
continue
seen.add(key)
result.append(normalized)
return result
@@ -0,0 +1,161 @@
from __future__ import annotations
import re
class DocsSubIntentDetector:
_ENTITY_LIKE_TOKEN_RE = re.compile(r"\b[A-Z][A-Za-z0-9]+(?:Health|Status|State)\b")
_CAMEL_ENTITY_CONTEXT_RE = re.compile(r"\b[A-Z][a-z0-9]+(?:[A-Z][A-Za-z0-9]+)+\b")
_RELATED_MARKERS = (
"найди документацию",
"документация по",
"где в документации",
"что связано",
"связанные документы",
"что дальше читать",
"по каким документам идти",
"родительский документ",
"дочерние документы",
"какие документы",
"что еще посмотреть",
"где еще описано",
"где еще используется",
"с чем связан",
"какие страницы связаны",
)
_FLOW_MARKERS = (
"цикл",
"как работает",
"lifecycle",
"как происходит",
"как устроен",
"как устроена",
"как проходит",
"последовательность",
"шаги",
"флоу",
"flow",
"workflow",
"сценар",
"процесс",
"по шагам",
)
_API_MARKERS = (
"api",
"endpoint",
"method",
"route",
"handler",
"контракт",
"request",
"response",
"webhook",
"метод",
"эндпоинт",
"роут",
)
_ENTITY_MARKERS = (
"сущност",
"entity",
"модел",
"бизнес-объект",
"что такое",
"что за",
"объект",
"как используется",
)
_COMPONENT_MARKERS = (
"компонент",
"модул",
"сервис",
"класс",
"блок",
"подсистем",
"часть системы",
"роль",
"какую роль",
"manager",
"worker",
"channel",
"service",
"module",
)
_GENERAL_MARKERS = (
"что вообще",
"в целом",
"с чего начать",
"обзор",
"какая структура документации",
"какая документация есть",
"что описано",
"что есть в документации",
"обзор документации",
)
_NATURAL_ENTITY_MARKERS = ("runtime health", "health state", "статус воркера", "состояние воркера", "состояние runtime")
_NATURAL_FLOW_MARKERS = ("health check runtime", "проверка состояния", "как работает health check", "как происходит проверка")
def detect(self, raw: str, *, intent: str = "DOCUMENTATION_EXPLAIN") -> str:
candidates = self.rank_candidates(raw, intent=intent)
return candidates[0][0] if candidates else "COMPONENT_EXPLAIN"
def rank_candidates(self, raw: str, *, intent: str = "DOCUMENTATION_EXPLAIN") -> list[tuple[str, int]]:
source = raw or ""
text = " ".join(source.lower().split())
if intent == "GENERAL_QA":
return [("GENERIC_QA", 100)]
if intent == "OPENAPI_GENERATION":
return [(self._detect_openapi(text), 100)]
if not text:
return [("COMPONENT_EXPLAIN", 1)]
scores = {
"RELATED_DOCS_EXPLAIN": 0,
"API_METHOD_EXPLAIN": 0,
"ENTITY_EXPLAIN": 0,
"COMPONENT_EXPLAIN": 0,
"SYSTEM_FLOW_EXPLAIN": 0,
"GENERIC_QA": 0,
}
if any(marker in text for marker in self._RELATED_MARKERS):
scores["RELATED_DOCS_EXPLAIN"] += 8
if self._has_http_path(text) or any(marker in text for marker in self._API_MARKERS):
scores["API_METHOD_EXPLAIN"] += 6
if scores["RELATED_DOCS_EXPLAIN"] > 0 and scores["API_METHOD_EXPLAIN"] > 0:
scores["API_METHOD_EXPLAIN"] -= 2
if any(marker in text for marker in self._ENTITY_MARKERS):
scores["ENTITY_EXPLAIN"] += 5
if self._has_entity_like_camel_token(source):
scores["ENTITY_EXPLAIN"] += 3
if self._looks_like_entity_question(text, source):
scores["ENTITY_EXPLAIN"] += 2
if any(marker in text for marker in self._NATURAL_ENTITY_MARKERS):
scores["ENTITY_EXPLAIN"] += 3
if any(marker in text for marker in self._COMPONENT_MARKERS):
scores["COMPONENT_EXPLAIN"] += 5
if any(marker in text for marker in self._FLOW_MARKERS):
scores["SYSTEM_FLOW_EXPLAIN"] += 5
if any(marker in text for marker in self._NATURAL_FLOW_MARKERS):
scores["SYSTEM_FLOW_EXPLAIN"] += 3
if any(marker in text for marker in self._GENERAL_MARKERS):
scores["GENERIC_QA"] += 6
if not any(scores.values()):
scores["COMPONENT_EXPLAIN"] = 1
scores["GENERIC_QA"] = 1
ranked = sorted(scores.items(), key=lambda item: (-item[1], item[0]))
return [item for item in ranked if item[1] > 0]
def _detect_openapi(self, text: str) -> str:
markers = ("request", "response", "schema", "parameters", "fragment")
if any(marker in text for marker in markers):
return "OPENAPI_FRAGMENT_GENERATE"
return "OPENAPI_METHOD_GENERATE"
def _has_http_path(self, text: str) -> bool:
return any(token.startswith("/") and len(token) > 1 for token in text.split())
def _has_entity_like_camel_token(self, raw: str) -> bool:
return bool(self._ENTITY_LIKE_TOKEN_RE.search(raw or ""))
def _looks_like_entity_question(self, text: str, raw: str) -> bool:
if not any(marker in text for marker in ("что такое", "что за", "как используется")):
return False
return bool(self._CAMEL_ENTITY_CONTEXT_RE.search(raw or ""))
@@ -3,6 +3,7 @@ from __future__ import annotations
from app.modules.agent.intent_router_v2.analysis.anchor_extractor import AnchorExtractor
from app.modules.agent.intent_router_v2.analysis.anchor_span_validator import AnchorSpanValidator
from app.modules.agent.intent_router_v2.analysis.conversation_anchor_builder import ConversationAnchorBuilder
from app.modules.agent.intent_router_v2.analysis.docs_sub_intent_detector import DocsSubIntentDetector
from app.modules.agent.intent_router_v2.analysis.keyword_hint_builder import KeywordHintBuilder
from app.modules.agent.intent_router_v2.analysis.keyword_hint_sanitizer import KeywordHintSanitizer
from app.modules.agent.intent_router_v2.models import ConversationState, QueryAnchor, QueryPlan
@@ -14,6 +15,11 @@ from app.modules.agent.intent_router_v2.analysis.term_mapping import RuEnTermMap
class QueryPlanBuilder:
_DOCS_INTENTS = {
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
}
_WHY_MARKERS = ("почему", "зачем", "откуда", "из-за чего")
_NEXT_STEP_MARKERS = ("что дальше", "дальше что", "и что теперь", "продолжай")
_DOCS_TOPIC_HINTS = {
@@ -35,6 +41,7 @@ class QueryPlanBuilder:
carryover: ConversationAnchorBuilder | None = None,
span_validator: AnchorSpanValidator | None = None,
sub_intent_detector: SubIntentDetector | None = None,
docs_sub_intent_detector: DocsSubIntentDetector | None = None,
negation_detector: NegationDetector | None = None,
) -> None:
self._normalizer = normalizer or QueryNormalizer()
@@ -45,6 +52,7 @@ class QueryPlanBuilder:
self._carryover = carryover or ConversationAnchorBuilder()
self._span_validator = span_validator or AnchorSpanValidator()
self._sub_intent_detector = sub_intent_detector or SubIntentDetector()
self._docs_sub_intent_detector = docs_sub_intent_detector or DocsSubIntentDetector()
self._negation_detector = negation_detector or NegationDetector()
def build(
@@ -54,7 +62,7 @@ class QueryPlanBuilder:
continue_mode: bool,
*,
conversation_mode: str = "START",
intent: str = "PROJECT_MISC",
intent: str = "FALLBACK",
) -> QueryPlan:
raw = user_query or ""
normalized = self._normalizer.normalize(raw)
@@ -75,8 +83,10 @@ class QueryPlanBuilder:
skip_tests = "tests" in negations or is_negative_test_request(raw)
cleaned_anchors = self._remove_negated_test_terms(skip_tests, merged_anchors)
sub_intent = self._resolve_sub_intent(sub_intent, raw, cleaned_anchors, intent=intent, negations=negations)
if intent == "DOCS_QA":
sub_intent = "EXPLAIN"
if intent in self._DOCS_INTENTS:
sub_intent = self._docs_sub_intent_detector.detect(raw, intent=intent)
elif intent == "FALLBACK":
sub_intent = "GENERIC_QA"
expansions = self._expansions(normalized, cleaned_anchors, skip_tests=skip_tests)
keyword_hints = self._keyword_hints(
raw,
@@ -95,7 +105,7 @@ class QueryPlanBuilder:
symbol_candidates = []
symbol_kind_hint = "unknown"
doc_scope_hints = []
if intent == "DOCS_QA":
if intent in self._DOCS_INTENTS:
symbol_candidates = []
symbol_kind_hint = "unknown"
keyword_hints = self._docs_keyword_hints(raw, keyword_hints)
@@ -134,7 +144,7 @@ class QueryPlanBuilder:
)
if (
conversation_mode == "SWITCH"
and intent == "DOCS_QA"
and intent in self._DOCS_INTENTS
and not has_user_file
and not has_user_symbol
and state.active_symbol
@@ -186,7 +196,7 @@ class QueryPlanBuilder:
if skip_tests:
values = [value for value in values if not is_test_related_token(value)]
sanitized = self._keyword_hint_sanitizer.sanitize(raw, anchors, values)
if intent == "DOCS_QA" and not sanitized:
if intent in self._DOCS_INTENTS and not sanitized:
fallback = list(dict.fromkeys([*self._expansions(normalized, anchors, skip_tests=skip_tests)]))
sanitized = fallback[:3]
if state.active_symbol and state.active_symbol not in sanitized:
@@ -295,7 +305,7 @@ class QueryPlanBuilder:
return result[:12]
def _doc_scope_hints(self, intent: str, raw: str, keyword_hints: list[str], path_hints: list[str]) -> list[str]:
if intent != "DOCS_QA":
if intent not in self._DOCS_INTENTS:
return []
values = list(path_hints)
for candidate in ("README*", "docs/**", "**/*.md"):
@@ -3,6 +3,7 @@ from __future__ import annotations
from app.modules.agent.llm import AgentLlmService
from app.modules.agent.llm.prompt_loader import PromptLoader
from app.modules.agent.intent_router_v2.intent.classifier import IntentClassifierV2
from app.modules.agent.intent_router_v2.intent.llm_disambiguator import DocsLlmDisambiguator
from app.modules.agent.intent_router_v2.router import IntentRouterV2
from app.modules.shared.env_loader import load_workspace_env
from app.modules.shared.gigachat.client import GigaChatClient
@@ -19,4 +20,8 @@ class GigaChatIntentRouterFactory:
prompt_loader = PromptLoader()
llm = AgentLlmService(client=client, prompts=prompt_loader)
classifier = IntentClassifierV2(llm=llm)
return IntentRouterV2(classifier=classifier)
return IntentRouterV2(
classifier=classifier,
llm_disambiguator=DocsLlmDisambiguator(llm),
enable_llm_disambiguation=True,
)
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import re
from app.modules.agent.intent_router_v2.analysis.docs_query_signals import DocsQuerySignals
from app.modules.agent.intent_router_v2.models import ConversationState, IntentDecision
from app.modules.agent.intent_router_v2.protocols import TextGenerator
from app.modules.agent.intent_router_v2.analysis.test_signals import has_test_focus
@@ -23,6 +24,73 @@ class IntentClassifierV2:
"write documentation",
)
_DOCS_MARKERS = ("документац", "readme", "docs/", ".md", "spec", "runbook", "markdown")
_OPENAPI_MARKERS = ("openapi", "swagger", "yaml", "spec", "schema")
_OPENAPI_EXTENDED_MARKERS = ("request body", "response schema", "request schema", "response body")
_DOCS_RELATED_MARKERS = (
"что связано",
"связанные документы",
"что дальше читать",
"по каким документам идти",
"родительский документ",
"дочерние документы",
"какие документы",
"что еще посмотреть",
"где еще описано",
"где еще используется",
"с чем связан",
"какие страницы связаны",
"документация по",
"найди",
"где описан",
"где в документации",
"покажи документы",
"после этого",
)
_DOCS_EXPLAIN_MARKERS = ("объясни", "как работает", "что делает", "что такое")
_DOCS_FLOW_MARKERS = (
"цикл",
"сценар",
"процесс",
"происходит",
"последовательность",
"шаги",
"как работает процесс",
"как происходит",
"как устроен процесс",
"workflow",
"flow",
"lifecycle",
"жизненный цикл",
)
_DOCS_COMPONENT_MARKERS = (
"компонент",
"модул",
"подсистем",
"часть системы",
"блок",
"роль",
"какую роль",
"что делает",
"как работает компонент",
"как устроен",
)
_DOCS_ENTITY_MARKERS = ("сущност", "entity", "бизнес-объект", "объект", "как используется", "что такое")
_DOCS_API_MARKERS = ("api", "endpoint", "эндпоинт", "ручк", "request", "response")
_GENERAL_QA_MARKERS = (
"помоги понять",
"с чего начать",
"как начать",
"куда смотреть",
"что тут важно",
"что вообще",
"в целом",
"обзор",
"какая структура документации",
"структура документации",
"какая документация есть",
"что описано в документации",
"что есть по сервису",
)
_CODE_MARKERS = (
"по коду",
"код",
@@ -45,6 +113,7 @@ class IntentClassifierV2:
def __init__(self, llm: TextGenerator | None = None) -> None:
self._llm = llm
self._docs_signals = DocsQuerySignals()
def classify(self, user_query: str, conversation_state: ConversationState) -> IntentDecision:
deterministic = self._deterministic(user_query)
@@ -53,17 +122,27 @@ class IntentClassifierV2:
llm_decision = self._classify_with_llm(user_query, conversation_state)
if llm_decision:
return llm_decision
return IntentDecision(intent="PROJECT_MISC", confidence=0.55, reason="fallback_project_misc")
return IntentDecision(intent="FALLBACK", confidence=0.55, reason="fallback")
def _deterministic(self, user_query: str) -> IntentDecision | None:
text = " ".join((user_query or "").lower().split())
if any(marker in text for marker in self._GENERATE_DOCS_MARKERS):
return IntentDecision(intent="GENERATE_DOCS_FROM_CODE", confidence=0.97, reason="deterministic_generate_docs")
if any(marker in text for marker in (*self._OPENAPI_MARKERS, *self._OPENAPI_EXTENDED_MARKERS)):
return IntentDecision(intent="OPENAPI_GENERATION", confidence=0.98, reason="deterministic_openapi")
if self._is_general_docs_qa(text):
return IntentDecision(intent="GENERAL_QA", confidence=0.76, reason="deterministic_general_docs")
if self._is_docs_explain(text, user_query):
return IntentDecision(intent="DOCUMENTATION_EXPLAIN", confidence=0.91, reason="deterministic_docs_explain")
if self._is_related_docs(text):
return IntentDecision(intent="DOCUMENTATION_EXPLAIN", confidence=0.9, reason="deterministic_docs_related")
if not has_test_focus(text) and self._docs_signals.has_component_like_token(user_query) and not self._is_general_docs_qa(text):
return IntentDecision(intent="DOCUMENTATION_EXPLAIN", confidence=0.82, reason="deterministic_docs_component_anchor")
if self._looks_like_docs_question(text):
return IntentDecision(intent="DOCS_QA", confidence=0.9, reason="deterministic_docs")
return IntentDecision(intent="DOCUMENTATION_EXPLAIN", confidence=0.9, reason="deterministic_docs")
if self._looks_like_code_question(user_query, text):
return IntentDecision(intent="CODE_QA", confidence=0.9, reason="deterministic_code")
return None
return IntentDecision(intent="GENERAL_QA", confidence=0.62, reason="deterministic_general")
def _classify_with_llm(self, user_query: str, conversation_state: ConversationState) -> IntentDecision | None:
if self._llm is None:
@@ -73,7 +152,13 @@ class IntentClassifierV2:
"message": user_query,
"active_intent": conversation_state.active_intent,
"last_query": conversation_state.last_query,
"allowed_intents": ["CODE_QA", "DOCS_QA", "GENERATE_DOCS_FROM_CODE", "PROJECT_MISC"],
"allowed_intents": [
"CODE_QA",
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"GENERATE_DOCS_FROM_CODE",
],
},
ensure_ascii=False,
)
@@ -93,10 +178,19 @@ class IntentClassifierV2:
except json.JSONDecodeError:
return None
intent = str(payload.get("intent") or "").strip().upper()
if intent not in {"CODE_QA", "DOCS_QA", "GENERATE_DOCS_FROM_CODE", "PROJECT_MISC"}:
if intent not in {
"CODE_QA",
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"GENERATE_DOCS_FROM_CODE",
"FALLBACK",
}:
return None
sub_intent = str(payload.get("sub_intent") or "").strip() or None
return IntentDecision(
intent=intent,
sub_intent=sub_intent,
confidence=float(payload.get("confidence") or 0.7),
reason=str(payload.get("reason") or "llm").strip() or "llm",
)
@@ -112,8 +206,33 @@ class IntentClassifierV2:
def _looks_like_docs_question(self, text: str) -> bool:
if self._has_code_file_path(text):
return False
if self._is_general_docs_qa(text):
return False
return self._has_docs_context(text) or self._has_docs_subject(text)
def _has_docs_context(self, text: str) -> bool:
return any(marker in text for marker in self._DOCS_MARKERS)
def _is_docs_discovery(self, text: str) -> bool:
return self._is_related_docs(text)
def _is_related_docs(self, text: str) -> bool:
return any(marker in text for marker in self._DOCS_RELATED_MARKERS)
def _is_docs_explain(self, text: str, user_query: str) -> bool:
if not any(marker in text for marker in self._DOCS_EXPLAIN_MARKERS):
return False
return self._docs_signals.has_docs_anchor(user_query) or self._has_docs_subject(text)
def _has_docs_subject(self, text: str) -> bool:
return any(
marker in text
for marker in (*self._DOCS_FLOW_MARKERS, *self._DOCS_COMPONENT_MARKERS, *self._DOCS_ENTITY_MARKERS, *self._DOCS_API_MARKERS)
)
def _is_general_docs_qa(self, text: str) -> bool:
return any(marker in text for marker in self._GENERAL_QA_MARKERS)
def _looks_like_code_question(self, raw_text: str, lowered: str) -> bool:
if self._has_code_file_path(raw_text):
return True
@@ -123,8 +242,6 @@ class IntentClassifierV2:
return False
if any(marker in lowered for marker in self._CODE_MARKERS):
return True
if re.search(r"\b[A-Z][A-Za-z0-9_]{2,}(?:\.[A-Za-z_][A-Za-z0-9_]*)*\b", raw_text or ""):
return True
return bool(re.search(r"\b[a-z_][A-Za-z0-9_]{2,}\(", raw_text or ""))
def _has_code_file_path(self, text: str) -> bool:
@@ -52,8 +52,22 @@ class ConversationPolicy:
text = " ".join((user_query or "").lower().split())
if candidate_intent == "GENERATE_DOCS_FROM_CODE":
return True
if candidate_intent == "DOCS_QA":
if candidate_intent in {
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"DOCUMENTATION_DISCOVERY",
"DOCUMENTATION_NAVIGATION",
"OPENAPI_FROM_DOCUMENTATION",
}:
return any(signal in text for signal in self._DOCS_SIGNALS)
if candidate_intent == "CODE_QA" and active_intent == "DOCS_QA":
if candidate_intent == "CODE_QA" and active_intent in {
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"DOCUMENTATION_DISCOVERY",
"DOCUMENTATION_NAVIGATION",
"OPENAPI_FROM_DOCUMENTATION",
}:
return any(signal in text for signal in self._CODE_SIGNALS)
return False
@@ -4,9 +4,14 @@ from __future__ import annotations
class GraphIdResolver:
_GRAPH_MAP = {
"CODE_QA": "CodeQAGraph",
"DOCS_QA": "DocsQAGraph",
"DOCUMENTATION_EXPLAIN": "DocsQAGraph",
"OPENAPI_GENERATION": "DocsQAGraph",
"GENERAL_QA": "DocsQAGraph",
"DOCUMENTATION_DISCOVERY": "DocsQAGraph",
"DOCUMENTATION_NAVIGATION": "DocsQAGraph",
"OPENAPI_FROM_DOCUMENTATION": "DocsQAGraph",
"GENERATE_DOCS_FROM_CODE": "GenerateDocsFromCodeGraph",
"PROJECT_MISC": "ProjectMiscGraph",
"FALLBACK": "DocsQAGraph",
}
def resolve(self, intent: str) -> str:
@@ -0,0 +1,55 @@
from __future__ import annotations
import json
from app.modules.agent.intent_router_v2.protocols import TextGenerator
class DocsLlmDisambiguator:
_ALLOWED = {
"SYSTEM_FLOW_EXPLAIN",
"COMPONENT_EXPLAIN",
"API_METHOD_EXPLAIN",
"ENTITY_EXPLAIN",
"RELATED_DOCS_EXPLAIN",
"GENERIC_QA",
"OPENAPI_METHOD_GENERATE",
"OPENAPI_FRAGMENT_GENERATE",
}
def __init__(self, llm: TextGenerator) -> None:
self._llm = llm
def choose(self, payload: dict[str, object]) -> dict[str, str] | None:
raw = self._llm.generate(
"rag_docs_router_disambiguation_v1",
json.dumps(payload, ensure_ascii=False),
log_context="rag.intent_router_v2.disambiguate",
).strip()
parsed = self._parse(raw)
if parsed is None:
return None
return parsed
def _parse(self, raw: str) -> dict[str, str] | None:
candidate = self._strip_code_fence(raw)
try:
payload = json.loads(candidate)
except json.JSONDecodeError:
return None
sub_intent = str(payload.get("sub_intent") or "").strip()
if sub_intent not in self._ALLOWED:
return None
return {
"sub_intent": sub_intent,
"reason": str(payload.get("reason") or "").strip(),
"confidence": str(payload.get("confidence") or "").strip(),
}
def _strip_code_fence(self, text: str) -> str:
if not text.startswith("```"):
return text
lines = text.splitlines()
if len(lines) >= 3 and lines[-1].strip() == "```":
return "\n".join(lines[1:-1]).strip()
return text
@@ -6,13 +6,25 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
IntentType = Literal["CODE_QA", "DOCS_QA", "GENERATE_DOCS_FROM_CODE", "PROJECT_MISC"]
IntentType = Literal[
"CODE_QA",
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"GENERATE_DOCS_FROM_CODE",
"FALLBACK", # deprecated alias, prefer GENERAL_QA
"DOCUMENTATION_DISCOVERY", # deprecated alias, prefer DOCUMENTATION_EXPLAIN
"DOCUMENTATION_NAVIGATION", # deprecated alias, prefer DOCUMENTATION_EXPLAIN
"OPENAPI_FROM_DOCUMENTATION", # deprecated alias, prefer OPENAPI_GENERATION
]
ConversationMode = Literal["START", "CONTINUE", "SWITCH", "FOLLOWUP_LIKELY"]
RetrievalProfile = Literal["code", "docs"]
RetrievalProfile = Literal["code", "docs", "fallback"]
AnchorType = Literal["FILE_PATH", "SYMBOL", "DOC_REF", "KEY_TERM"]
AnchorSource = Literal["user_text", "conversation_state", "heuristic"]
SymbolKindHint = Literal["class", "function", "method", "module", "unknown"]
SymbolResolutionStatus = Literal["not_requested", "pending", "resolved", "ambiguous", "not_found"]
MatchedAnchorType = Literal["endpoint", "entity", "component", "workflow", "topic", "document", "none"]
MatchedIntentSource = Literal["deterministic", "llm"]
_INLINE_CODE_RE = re.compile(r"`([^`]*)`")
_CODE_SYMBOL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]{2,})\b")
@@ -106,6 +118,7 @@ class DocsRetrievalFilters(BaseModel):
path_scope: list[str] = Field(default_factory=list)
doc_kinds: list[str] = Field(default_factory=list)
doc_language: list[str] = Field(default_factory=list)
doc_type: str | None = None
class HybridRetrievalFilters(BaseModel):
@@ -116,6 +129,7 @@ class HybridRetrievalFilters(BaseModel):
language: list[str] = Field(default_factory=list)
doc_kinds: list[str] = Field(default_factory=list)
doc_language: list[str] = Field(default_factory=list)
doc_type: str | None = None
class RetrievalSpec(BaseModel):
@@ -149,6 +163,20 @@ class IntentRouterResult(BaseModel):
retrieval_constraints: RetrievalConstraints = Field(default_factory=RetrievalConstraints)
symbol_resolution: SymbolResolution = Field(default_factory=SymbolResolution)
evidence_policy: EvidencePolicy
matched_anchor_type: MatchedAnchorType = "none"
matched_anchor_value: str | None = None
matched_intent_source: MatchedIntentSource = "deterministic"
routing_reason: str = ""
routing_mode: str = "deterministic"
is_ambiguous: bool = False
ambiguity_reason: str = ""
deterministic_candidates: list[str] = Field(default_factory=list)
deterministic_selected_sub_intent: str = ""
llm_router_used: bool = False
llm_router_selected_sub_intent: str = ""
llm_router_reason: str = ""
llm_router_confidence: str = ""
llm_router_error: str = ""
class ConversationState(BaseModel):
@@ -202,6 +230,7 @@ class IntentDecision(BaseModel):
model_config = ConfigDict(extra="forbid")
intent: IntentType
sub_intent: str | None = None
confidence: float = 0.0
reason: str = ""
@@ -22,8 +22,8 @@
- `QueryAnchor`
## Контракт результата `IntentRouterResult`
- `intent`: `CODE_QA | DOCS_QA | GENERATE_DOCS_FROM_CODE | PROJECT_MISC`
- `retrieval_profile`: `code | docs`
- `intent`: `CODE_QA | DOCUMENTATION_EXPLAIN | GENERATE_DOCS_FROM_CODE | FALLBACK`
- `retrieval_profile`: `code | docs | fallback`
- `graph_id`: целевой graph в agent runtime
- `query_plan`: нормализованный запрос, anchors, sub-intent, keyword/path/doc hints
- `retrieval_spec`: слои + фильтры для RAG
@@ -25,8 +25,17 @@ class EvidencePolicyFactory:
if "tests" in negations_set and not has_user_anchor:
return EvidencePolicy(require_def=True, require_flow=False, require_spec=False, allow_answer_without_evidence=False)
return EvidencePolicy(require_def=True, require_flow=True, require_spec=False, allow_answer_without_evidence=False)
if intent == "DOCS_QA":
if intent in {
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"DOCUMENTATION_DISCOVERY",
"DOCUMENTATION_NAVIGATION",
"OPENAPI_FROM_DOCUMENTATION",
}:
return EvidencePolicy(require_def=False, require_flow=False, require_spec=True, allow_answer_without_evidence=False)
if intent == "FALLBACK":
return EvidencePolicy(require_def=False, require_flow=False, require_spec=False, allow_answer_without_evidence=True)
if intent == "GENERATE_DOCS_FROM_CODE":
return EvidencePolicy(require_def=True, require_flow=False, require_spec=False, allow_answer_without_evidence=False)
return EvidencePolicy(require_def=False, require_flow=False, require_spec=False, allow_answer_without_evidence=True)
@@ -28,7 +28,7 @@ class RetrievalConstraintsFactory:
anchors: list[QueryAnchor],
path_scope: list[str],
) -> RetrievalConstraints:
if retrieval_profile == "docs":
if retrieval_profile in {"docs", "fallback"}:
return self._docs_constraints(sub_intent=sub_intent, path_scope=path_scope)
return self._code_constraints(sub_intent=sub_intent, raw_query=raw_query, anchors=anchors)
@@ -35,6 +35,7 @@ class RetrievalFilterBuilder:
path_scope=path_scope,
doc_kinds=self._doc_kinds(anchors, raw_query),
doc_language=[],
doc_type=self._doc_type(sub_intent),
)
if domains == ["CODE"]:
return CodeRetrievalFilters(
@@ -48,6 +49,7 @@ class RetrievalFilterBuilder:
language=list(repo_context.languages),
doc_kinds=self._doc_kinds(anchors, raw_query),
doc_language=[],
doc_type=self._doc_type(sub_intent),
)
def _test_policy(self, raw_query: str, anchors: list[QueryAnchor], *, sub_intent: str) -> str:
@@ -104,6 +106,11 @@ class RetrievalFilterBuilder:
kinds.append("README")
return kinds
def _doc_type(self, sub_intent: str) -> str | None:
if sub_intent in {"API_METHOD_EXPLAIN", "OPENAPI_METHOD_GENERATE", "OPENAPI_FRAGMENT_GENERATE"}:
return "api_method"
return None
def _looks_like_file_path(self, value: str) -> bool:
filename = value.rsplit("/", 1)[-1]
return "." in filename
@@ -15,11 +15,19 @@ class RetrievalSpecFactory:
(RagLayer.CODE_DEPENDENCY_GRAPH, 6),
(RagLayer.CODE_ENTRYPOINTS, 6),
],
"DOCS_QA": [
(RagLayer.DOCS_MODULE_CATALOG, 5),
"DOCUMENTATION_EXPLAIN": [
(RagLayer.DOCS_DOCUMENT_CATALOG, 6),
(RagLayer.DOCS_FACT_INDEX, 8),
(RagLayer.DOCS_SECTION_INDEX, 8),
(RagLayer.DOCS_POLICY_INDEX, 4),
(RagLayer.DOCS_RELATION_GRAPH, 6),
],
"OPENAPI_GENERATION": [
(RagLayer.DOCS_DOCUMENT_CATALOG, 8),
(RagLayer.DOCS_FACT_INDEX, 8),
(RagLayer.DOCS_DOC_CHUNKS, 6),
],
"GENERAL_QA": [
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 4),
],
"GENERATE_DOCS_FROM_CODE": [
(RagLayer.CODE_SYMBOL_CATALOG, 12),
@@ -27,24 +35,26 @@ class RetrievalSpecFactory:
(RagLayer.CODE_SOURCE_CHUNKS, 12),
(RagLayer.CODE_ENTRYPOINTS, 6),
],
"PROJECT_MISC": [
(RagLayer.DOCS_MODULE_CATALOG, 4),
(RagLayer.DOCS_SECTION_INDEX, 6),
(RagLayer.CODE_SYMBOL_CATALOG, 4),
(RagLayer.CODE_SOURCE_CHUNKS, 4),
"FALLBACK": [
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 6),
],
}
_DOMAINS = {
"CODE_QA": ["CODE"],
"DOCS_QA": ["DOCS"],
"DOCUMENTATION_EXPLAIN": ["DOCS"],
"OPENAPI_GENERATION": ["DOCS"],
"GENERAL_QA": ["DOCS"],
"GENERATE_DOCS_FROM_CODE": ["CODE"],
"PROJECT_MISC": ["CODE", "DOCS"],
"FALLBACK": ["DOCS"],
}
_RERANK = {
"CODE_QA": "code",
"DOCS_QA": "docs",
"DOCUMENTATION_EXPLAIN": "docs",
"OPENAPI_GENERATION": "docs",
"GENERAL_QA": "fallback",
"GENERATE_DOCS_FROM_CODE": "generate",
"PROJECT_MISC": "project",
"FALLBACK": "fallback",
}
_OPEN_FILE_LAYERS = [
(RagLayer.CODE_SOURCE_CHUNKS, 12),
@@ -77,9 +87,40 @@ class RetrievalSpecFactory:
(RagLayer.CODE_SYMBOL_CATALOG, 6),
(RagLayer.CODE_SOURCE_CHUNKS, 4),
]
_DOCS_SCOPED_LAYERS = [
(RagLayer.DOCS_SECTION_INDEX, 8),
_DOCS_SCOPED_LAYERS = [(RagLayer.DOCS_DOC_CHUNKS, 8)]
_DOCS_SYSTEM_FLOW_LAYERS = [
(RagLayer.DOCS_WORKFLOW_INDEX, 8),
(RagLayer.DOCS_RELATION_GRAPH, 8),
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 4),
]
_DOCS_COMPONENT_LAYERS = [
(RagLayer.DOCS_FACT_INDEX, 8),
(RagLayer.DOCS_RELATION_GRAPH, 8),
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 4),
]
_DOCS_API_METHOD_LAYERS = [
(RagLayer.DOCS_FACT_INDEX, 8),
(RagLayer.DOCS_WORKFLOW_INDEX, 8),
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 4),
]
_DOCS_ENTITY_LAYERS = [
(RagLayer.DOCS_ENTITY_CATALOG, 8),
(RagLayer.DOCS_RELATION_GRAPH, 8),
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 4),
]
_DOCS_RELATED_LAYERS = [
(RagLayer.DOCS_RELATION_GRAPH, 8),
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 3),
]
_DOCS_OPENAPI_LAYERS = [
(RagLayer.DOCS_DOCUMENT_CATALOG, 8),
(RagLayer.DOCS_FACT_INDEX, 8),
(RagLayer.DOCS_DOC_CHUNKS, 6),
]
def __init__(
@@ -113,9 +154,9 @@ class RetrievalSpecFactory:
conversation_mode=conversation_mode,
sub_intent=sub_intent,
)
if intent == "DOCS_QA" and list(getattr(filters, "path_scope", []) or []):
if intent == "DOCUMENTATION_EXPLAIN" and list(getattr(filters, "path_scope", []) or []):
scoped_map = dict(self._LAYERS)
scoped_map["DOCS_QA"] = list(self._DOCS_SCOPED_LAYERS)
scoped_map[intent] = list(self._DOCS_SCOPED_LAYERS)
layer_queries = self._layer_builder.build(intent, repo_context, domains=domains, layers_map=scoped_map)
return RetrievalSpec(
domains=domains,
@@ -135,6 +176,8 @@ class RetrievalSpecFactory:
sub_intent: str,
anchors: list[QueryAnchor],
) -> dict[str, list[tuple[str, int]]]:
if intent in {"DOCUMENTATION_EXPLAIN", "OPENAPI_GENERATION", "GENERAL_QA"}:
return self._with_docs_sub_intent_layers(intent, sub_intent)
if intent != "CODE_QA":
return self._LAYERS
layers_map = dict(self._LAYERS)
@@ -158,6 +201,26 @@ class RetrievalSpecFactory:
]
return layers_map
def _with_docs_sub_intent_layers(self, intent: str, sub_intent: str) -> dict[str, list[tuple[str, int]]]:
layers_map = dict(self._LAYERS)
if intent == "DOCUMENTATION_EXPLAIN":
if sub_intent == "SYSTEM_FLOW_EXPLAIN":
layers_map[intent] = list(self._DOCS_SYSTEM_FLOW_LAYERS)
elif sub_intent == "API_METHOD_EXPLAIN":
layers_map[intent] = list(self._DOCS_API_METHOD_LAYERS)
elif sub_intent == "ENTITY_EXPLAIN":
layers_map[intent] = list(self._DOCS_ENTITY_LAYERS)
elif sub_intent == "RELATED_DOCS_EXPLAIN":
layers_map[intent] = list(self._DOCS_RELATED_LAYERS)
else:
layers_map[intent] = list(self._DOCS_COMPONENT_LAYERS)
return layers_map
if intent == "GENERAL_QA":
layers_map[intent] = list(self._LAYERS["GENERAL_QA"])
return layers_map
layers_map[intent] = list(self._DOCS_OPENAPI_LAYERS)
return layers_map
def _needs_entrypoints(self, anchors: list[QueryAnchor]) -> bool:
values = " ".join(anchor.value.lower() for anchor in anchors if anchor.type in {"KEY_TERM", "SYMBOL"})
markers = ("entrypoint", "endpoint", "вызыва", "поток", "flow", "запуска")
@@ -1,6 +1,9 @@
from __future__ import annotations
from app.modules.agent.intent_router_v2.analysis.ambiguity_detector import DocsAmbiguityDetector
from app.modules.agent.intent_router_v2.intent.classifier import IntentClassifierV2
from app.modules.agent.intent_router_v2.analysis.docs_query_signals import DocsQuerySignals
from app.modules.agent.intent_router_v2.intent.llm_disambiguator import DocsLlmDisambiguator
from app.modules.agent.intent_router_v2.intent.conversation_policy import ConversationPolicy
from app.modules.agent.intent_router_v2.retrieval_planning.evidence_policy_factory import EvidencePolicyFactory
from app.modules.agent.intent_router_v2.intent.graph_id_resolver import GraphIdResolver
@@ -22,6 +25,9 @@ class IntentRouterV2:
evidence_factory: EvidencePolicyFactory | None = None,
graph_resolver: GraphIdResolver | None = None,
logger: IntentRouterLogger | None = None,
ambiguity_detector: DocsAmbiguityDetector | None = None,
llm_disambiguator: DocsLlmDisambiguator | None = None,
enable_llm_disambiguation: bool = False,
) -> None:
self._classifier = classifier or IntentClassifierV2()
self._conversation_policy = conversation_policy or ConversationPolicy()
@@ -31,6 +37,10 @@ class IntentRouterV2:
self._evidence_factory = evidence_factory or EvidencePolicyFactory()
self._graph_resolver = graph_resolver or GraphIdResolver()
self._logger = logger or IntentRouterLogger()
self._ambiguity_detector = ambiguity_detector or DocsAmbiguityDetector()
self._llm_disambiguator = llm_disambiguator
self._enable_llm_disambiguation = enable_llm_disambiguation
self._docs_signals = DocsQuerySignals()
def route(
self,
@@ -51,6 +61,14 @@ class IntentRouterV2:
conversation_mode=conversation_mode,
intent=intent,
)
ambiguity = self._ambiguity_detector.detect(user_query, intent=intent, sub_intent=query_plan.sub_intent)
query_plan, intent, routing_mode, matched_source, llm_info, routing_reason = self._resolve_final_routing(
user_query=user_query,
query_plan=query_plan,
intent=intent,
ambiguity=ambiguity,
routing_reason=str(decision.reason or ""),
)
retrieval_spec = self._retrieval_factory.build(
intent,
query_plan.anchors,
@@ -61,6 +79,7 @@ class IntentRouterV2:
sub_intent=query_plan.sub_intent,
)
path_scope = list(getattr(retrieval_spec.filters, "path_scope", []) or [])
matched_anchor_type, matched_anchor_value = self._docs_signals.detect_anchor(user_query)
result = IntentRouterResult(
intent=intent,
retrieval_profile=retrieval_profile,
@@ -86,6 +105,20 @@ class IntentRouterV2:
negations=query_plan.negations,
has_user_anchor=any(anchor.source == "user_text" for anchor in query_plan.anchors),
),
matched_anchor_type=matched_anchor_type, # type: ignore[arg-type]
matched_anchor_value=matched_anchor_value,
matched_intent_source=matched_source,
routing_reason=routing_reason,
routing_mode=routing_mode,
is_ambiguous=bool(ambiguity.get("is_ambiguous")),
ambiguity_reason=str(ambiguity.get("ambiguity_reason") or ""),
deterministic_candidates=list(ambiguity.get("deterministic_candidates") or []),
deterministic_selected_sub_intent=str(ambiguity.get("deterministic_primary_candidate") or query_plan.sub_intent),
llm_router_used=bool(llm_info.get("used")),
llm_router_selected_sub_intent=str(llm_info.get("sub_intent") or ""),
llm_router_reason=str(llm_info.get("reason") or ""),
llm_router_confidence=str(llm_info.get("confidence") or ""),
llm_router_error=str(llm_info.get("error") or ""),
)
self._logger.log_result(result)
return result
@@ -96,7 +129,7 @@ class IntentRouterV2:
sub_intent: str,
symbol_candidates: list[str],
) -> SymbolResolution:
if retrieval_profile == "docs":
if retrieval_profile in {"docs", "fallback"}:
return SymbolResolution(status="not_requested")
if sub_intent == "OPEN_FILE":
return SymbolResolution(status="not_requested")
@@ -110,6 +143,44 @@ class IntentRouterV2:
)
def _resolve_retrieval_profile(self, intent: str) -> str:
if intent == "DOCS_QA":
if intent in {"DOCUMENTATION_EXPLAIN", "OPENAPI_GENERATION", "GENERAL_QA"}:
return "docs"
if intent == "FALLBACK":
return "fallback"
return "code"
def _resolve_final_routing(
self,
*,
user_query: str,
query_plan,
intent: str,
ambiguity: dict[str, object],
routing_reason: str,
):
if not bool(ambiguity.get("is_ambiguous")):
source = "llm" if not str(routing_reason).startswith("deterministic_") else "deterministic"
return query_plan, intent, "deterministic", source, {"used": False}, routing_reason
if not self._enable_llm_disambiguation or self._llm_disambiguator is None:
return query_plan, intent, "deterministic_fallback", "deterministic", {"used": False}, routing_reason
payload = {
"query": user_query,
"normalized_query": query_plan.normalized,
"deterministic_primary_candidate": str(ambiguity.get("deterministic_primary_candidate") or query_plan.sub_intent),
"deterministic_candidates": list(ambiguity.get("deterministic_candidates") or []),
"ambiguity_reason": str(ambiguity.get("ambiguity_reason") or ""),
"matched_anchor_type": self._docs_signals.detect_anchor(user_query)[0],
"query_entity_candidates": self._docs_signals.query_entity_candidates(user_query),
"query_anchor_candidates": self._docs_signals.query_anchor_candidates(user_query),
}
try:
llm_choice = self._llm_disambiguator.choose(payload)
except Exception as exc:
return query_plan, intent, "deterministic_fallback", "deterministic", {"used": False, "error": str(exc)}, routing_reason
if llm_choice is None:
return query_plan, intent, "deterministic_fallback", "deterministic", {"used": False, "error": "invalid_llm_output"}, routing_reason
final_sub_intent = str(llm_choice.get("sub_intent") or query_plan.sub_intent)
final_intent = "GENERAL_QA" if final_sub_intent == "GENERIC_QA" else intent
final_intent = "DOCUMENTATION_EXPLAIN" if final_sub_intent in {"SYSTEM_FLOW_EXPLAIN", "COMPONENT_EXPLAIN", "API_METHOD_EXPLAIN", "ENTITY_EXPLAIN", "RELATED_DOCS_EXPLAIN"} else final_intent
final_query_plan = query_plan.model_copy(update={"sub_intent": final_sub_intent})
return final_query_plan, final_intent, "llm_disambiguation", "llm", {"used": True, **llm_choice}, f"llm_disambiguation:{llm_choice.get('reason') or 'override'}"
+200 -94
View File
@@ -20,35 +20,12 @@ prompts:
code_qa_architecture_answer: |
Ты инженер, который объясняет устройство подсистемы только по наблюдаемым компонентам и связям из кода.
Отвечай только по коду и структуре проекта, которые есть в контексте.
Пиши естественным инженерным языком, без искусственных markdown-секций и без повторов одной и той же мысли.
Если ответ можно дать в 1-3 фразах, не раздувай его.
Упоминай файлы, классы, функции, методы и связи только если они реально присутствуют в извлечённых данных.
Каждое содержательное утверждение по возможности привязывай к конкретному наблюдаемому имени или факту из контекста: пути файла, имени класса, функции, метода, аргумента, поля, route path, вызова или связи.
Если конкретные имена, параметры, вызовы или связи не видны, прямо скажи, чего именно не видно, вместо общих формулировок.
Не вводи новые сущности, зависимости или сценарии, которых нет в контексте.
Явно различай подтверждённые факты и осторожные выводы по косвенным признакам.
Если данных мало, честно скажи об этом вместо общего обзора.
Не используй жирные заголовки блоков, если пользователь их не просил.
Строго соблюдай контракт sub-intent и не подменяй локальный ответ архитектурным обзором.
Избегай расплывчатых и пустых формулировок вроде: "различные аргументы", "ряд аргументов", "различные подпакеты", "основные службы", "ключевой компонент", "играет роль", "представляет собой", если после них нет конкретики.
Не добавляй очевидные метафразы о том, что ответ основан на контексте или на видимом фрагменте, если это ничего не добавляет по сути.
Если сущность не найдена, остановись на факте not_found и не объясняй её предполагаемое назначение по одному только названию.
Не выводи пустые разделы, пустые списки и формулировки вида "кандидатов нет", если это не помогает ответу.
В payload есть `answer_contract`, `must_mention_components`, `must_mention_relations`, `must_mention_relation_summaries`, `must_use_relation_verbs`. Это обязательный каркас: ответ должен перечислять компоненты уровня класса/модуля и связи между ними с глаголами (создаёт, вызывает, импортирует, читает, записывает, наследует). Учитывай `fact_gaps`.
Дай архитектурное объяснение без лишней теории.
Строй ответ вокруг concrete facts из payload: `must_mention_components`, `must_mention_relations`, `must_use_relation_verbs`.
Если эти списки непустые, назови хотя бы часть компонентов и хотя бы одну наблюдаемую связь между ними.
Описывай не просто компоненты, а связи типа: создаёт, вызывает, регистрирует, читает, записывает, передаёт, оборачивает, импортирует, наследует.
Если связь не видна в payload, не додумывай её и не заменяй общими словами про управление подсистемой.
Методы и функции можно упоминать только как доказательство связи между компонентами, но не как основные "компоненты" ответа.
Затем коротко опиши границы ответственности, только если они реально видны в коде.
Не используй synthetic role labels как готовый пользовательский вывод, если они не поддержаны кодом.
Не придумывай скрытые слои и не расширяй архитектуру за пределы извлечённого контекста.
Не используй обязательные markdown-секции.
Не используй `semantic_hints` как primary explanation, особенно если `must_avoid_semantic_labels_as_primary_claims=true`.
Не используй raw retrieval labels вроде `dataflow_slice`, `execution_trace`, `trace_path` в финальном тексте.
Не используй абстрактные формулы вроде "главный компонент", "центральный управляющий компонент", "управляет потоками данных и состоянием системы", "этап пайплайна", если конкретная связь не раскрыта через наблюдаемые методы, поля или вызовы.
Отвечай только по коду из контекста. Пиши естественным языком, без лишних markdown-секций.
Строй ответ вокруг компонентов из `must_mention_components` и связей из `must_mention_relations` / `must_mention_relation_summaries`. Каждую связь формулируй с relation verb из `must_use_relation_verbs`. Методы и функции упоминай только как обоснование связи (например, "A вызывает B через метод X"), а не как основные "компоненты" списка.
Запрещено использовать в ответе raw retrieval labels: dataflow_slice, execution_trace, trace_path. Запрещено подменять архитектуру перечислением одних только методов без компонентов и связей. Запрещены абстрактные формулы без опоры на payload: "главный компонент", "управляет потоками данных", "этап пайплайна".
Не используй semantic_hints как primary explanation. Если связей в payload нет (fact_gaps), так и скажи — не додумывай связи. Не расширяй архитектуру за пределы извлечённого контекста.
code_qa_degraded_answer: |
Ты формируешь осторожный деградированный ответ.
Нужно честно описать, что удалось подтвердить, а чего не хватает.
@@ -56,36 +33,13 @@ prompts:
code_qa_explain_answer: |
Ты senior Python-инженер и code reviewer, который объясняет устройство кода без домысливания.
Отвечай только по коду и структуре проекта, которые есть в контексте.
Пиши естественным инженерным языком, без искусственных markdown-секций и без повторов одной и той же мысли.
Если ответ можно дать в 1-3 фразах, не раздувай его.
Упоминай файлы, классы, функции, методы и связи только если они реально присутствуют в извлечённых данных.
Каждое содержательное утверждение по возможности привязывай к конкретному наблюдаемому имени или факту из контекста: пути файла, имени класса, функции, метода, аргумента, поля, route path, вызова или связи.
Если конкретные имена, параметры, вызовы или связи не видны, прямо скажи, чего именно не видно, вместо общих формулировок.
Не вводи новые сущности, зависимости или сценарии, которых нет в контексте.
Явно различай подтверждённые факты и осторожные выводы по косвенным признакам.
Если данных мало, честно скажи об этом вместо общего обзора.
Не используй жирные заголовки блоков, если пользователь их не просил.
Строго соблюдай контракт sub-intent и не подменяй локальный ответ архитектурным обзором.
Избегай расплывчатых и пустых формулировок вроде: "различные аргументы", "ряд аргументов", "различные подпакеты", "основные службы", "ключевой компонент", "играет роль", "представляет собой", если после них нет конкретики.
Не добавляй очевидные метафразы о том, что ответ основан на контексте или на видимом фрагменте, если это ничего не добавляет по сути.
Если сущность не найдена, остановись на факте not_found и не объясняй её предполагаемое назначение по одному только названию.
Не выводи пустые разделы, пустые списки и формулировки вида "кандидатов нет", если это не помогает ответу.
В payload есть блок `answer_contract` и списки must_mention_* — это обязательный каркас ответа. Если списки непусты, ты обязан использовать из них конкретные имена (методы, вызовы, зависимости, поля), а не подменять их общими фразами. Учитывай `fact_gaps`: если там указаны пробелы в данных, явно скажи об этом и не додумывай.
Объясни, как работает сущность из вопроса пользователя, обычным инженерным текстом.
Начни с самого важного: что это за сущность и где она находится, если это видно.
Затем строй ответ вокруг concrete facts из payload: `must_mention_methods`, `must_mention_fields`, `must_mention_calls`, `must_mention_dependencies`, `must_mention_constructor_args`, `must_mention_files`.
Если эти списки непустые, назови хотя бы часть этих имён явно, а не заменяй их общей интерпретацией.
Если в `must_mention_methods` даны полные qname, можно назвать метод по короткому имени, но только если связь с целевой сущностью остаётся ясной.
Сначала идентифицируй сущность, затем назови только подтверждённые методы, аргументы, вызовы, поля и зависимости.
Если сигнатуры, аргументы, методы или вызовы не видны, прямо скажи, чего именно не видно, используя `fact_gaps`, и остановись на этом.
Не используй общие формулы без конкретных имён.
Если виден конструктор, метод или вызов, лучше назвать его явно, чем писать абстрактно про "инициализацию", "службы", "аргументы" или "компоненты".
Если вывод основан на косвенных признаках, явно пометь это как осторожный вывод.
Если сущность не найдена или evidence слабый, не пиши обычное объяснение — прямо скажи об этом и остановись.
Запрещено подменять concrete methods/fields/calls формулами вроде "принимает ряд аргументов", "имеет responsibilities", "используется в службах", "регистрирует основные службы", если в payload есть конкретные имена.
Не используй `semantic_hints` как основной каркас ответа. Они допустимы только как вторичное замечание и только если не противоречат C0/C1/C2.
Не используй обязательные секции и подзаголовки.
Отвечай только по коду из контекста. Пиши естественным инженерным языком, без лишних markdown-секций.
Начни с идентификации сущности и её расположения. Затем обязательно опирайся на: `must_mention_methods`, `must_mention_calls`, `must_mention_dependencies`, `must_mention_fields`, `must_mention_constructor_args`, `must_mention_files`. Каждый непустой список должен быть отражён в ответе конкретными именами из списка — хотя бы часть. Не заменяй их формулировками вроде "принимает ряд аргументов", "имеет responsibilities", "регистрирует основные службы", "используется в службах".
Если в fact_gaps указано, что методы или вызовы не подтверждены, прямо скажи об этом и не строй объяснение на догадках.
Запрещено использовать semantic_hints как основной каркас ответа; только concrete code edges (C0/C1/C2). Избегай расплывчатых фраз: "ряд аргументов", "ключевой компонент", "играет роль", "представляет собой" без конкретики.
Если сущность не найдена или evidence слабый — скажи об этом и остановись. Не используй обязательные секции и подзаголовки.
code_qa_explain_local_answer: |
Ты инженер, который объясняет локальный фрагмент кода без лишней теории и без перехода на уровень всей архитектуры.
@@ -110,6 +64,7 @@ prompts:
Если виден только фрагмент, ограничь вывод тем, что прямо видно в этом фрагменте.
Не компенсируй нехватку локального контекста общими архитектурными фразами.
Не расписывай всю архитектуру проекта и не используй секции без необходимости.
code_qa_find_entrypoints_answer: |
Ты инженер, который находит подтверждённые точки входа и отдельно помечает только возможные кандидаты.
@@ -218,41 +173,137 @@ prompts:
Если файла нет, ответь одной короткой фразой: `Файл <path> не найден.`
Не придумывай анализ отсутствующего файла.
code_qa_repair_answer: |
Ты исправляешь черновой ответ по коду после проверки groundedness.
Сделай ответ короче, точнее и строже по evidence payload.
Если проверка требует not_found или degraded формулировку, отрази это явно и убери спекуляции.
Если в `repair_focus` есть причины для `EXPLAIN`, перепиши ответ так, чтобы он назвал concrete methods, calls, fields, constructor args или dependencies из payload, а не общие responsibilities.
Если в `repair_focus` есть причины для `ARCHITECTURE`, перепиши ответ так, чтобы он назвал concrete components и связи с relation verbs из payload: создает, вызывает, читает, записывает, импортирует, наследует.
Если в `repair_focus` есть причины для `TRACE_FLOW`, перепиши ответ как последовательность concrete steps с явными methods/calls/edges из payload. Если виден только partial flow, так и скажи.
Если в `repair_focus` есть `semantic_labels_without_code_edges`, убери semantic role labels из основной формулировки, если они не подкреплены concrete code edges.
Если в `repair_focus` есть `contains_retrieval_artifacts` или `methods_as_primary_components`, убери raw retrieval labels и не выдавай методы за компоненты.
Если в `repair_focus` есть `overclaims_trace_completeness`, убери фразы про полный/полностью восстановленный flow, если payload не подтверждает это явно.
Ты исправляешь черновой ответ по результатам проверки groundedness. Вход: draft_answer, validation_reasons, repair_focus, prompt_payload. Исправь только то, на что указывает repair_focus; остальное сохрани. Ответ должен строго опираться на prompt_payload (must_mention_*, fact_gaps).
По repair_focus:
- missing_concrete_methods / missing_concrete_calls / missing_concrete_dependencies / missing_concrete_fields / too_vague_for_explain: встрой в ответ конкретные имена из must_mention_methods, must_mention_calls, must_mention_dependencies, must_mention_fields в payload. Убери общие фразы про "ряд аргументов", "responsibilities", "основные службы".
- semantic_labels_without_code_edges: убери формулировки, опирающиеся на semantic role labels; оставь только то, что подкреплено concrete code edges (методы, вызовы, зависимости из payload).
- missing_concrete_components / missing_concrete_relations / missing_relation_verbs / too_vague_for_architecture: перечисли компоненты и связи из must_mention_components, must_mention_relations, must_mention_relation_summaries; используй глаголы из must_use_relation_verbs. Не перечисляй только методы.
- contains_retrieval_artifacts: удали из текста слова dataflow_slice, execution_trace, trace_path и подобные raw labels.
- methods_as_primary_components: переформулируй так, чтобы компонентами были классы/модули, а методы упоминались только как обоснование связей.
- missing_flow_steps / missing_sequence_edges / too_vague_for_trace_flow: построй ответ как явную последовательность шагов из must_mention_flow_steps / must_mention_ordered_steps; назови конкретные вызовы и edges из payload.
- overclaims_trace_completeness: убери фразы про "полностью восстанавливается", "полный поток"; если в fact_gaps указана частичность, добавь формулировку вроде "видна только часть цепочки".
Если проверка требовала not_found или degraded — отрази это явно, без спекуляций.
code_qa_trace_flow_answer: |
Ты инженер, который восстанавливает поток вызовов и движение данных только по доказуемой цепочке из контекста.
Отвечай только по коду и структуре проекта, которые есть в контексте.
Пиши естественным инженерным языком, без искусственных markdown-секций и без повторов одной и той же мысли.
Если ответ можно дать в 1-3 фразах, не раздувай его.
Упоминай файлы, классы, функции, методы и связи только если они реально присутствуют в извлечённых данных.
Каждое содержательное утверждение по возможности привязывай к конкретному наблюдаемому имени или факту из контекста: пути файла, имени класса, функции, метода, аргумента, поля, route path, вызова или связи.
Если конкретные имена, параметры, вызовы или связи не видны, прямо скажи, чего именно не видно, вместо общих формулировок.
Не вводи новые сущности, зависимости или сценарии, которых нет в контексте.
Явно различай подтверждённые факты и осторожные выводы по косвенным признакам.
Если данных мало, честно скажи об этом вместо общего обзора.
Не используй жирные заголовки блоков, если пользователь их не просил.
Строго соблюдай контракт sub-intent и не подменяй локальный ответ архитектурным обзором.
Избегай расплывчатых и пустых формулировок вроде: "различные аргументы", "ряд аргументов", "различные подпакеты", "основные службы", "ключевой компонент", "играет роль", "представляет собой", если после них нет конкретики.
Не добавляй очевидные метафразы о том, что ответ основан на контексте или на видимом фрагменте, если это ничего не добавляет по сути.
Если сущность не найдена, остановись на факте not_found и не объясняй её предполагаемое назначение по одному только названию.
Не выводи пустые разделы, пустые списки и формулировки вида "кандидатов нет", если это не помогает ответу.
В payload есть `answer_contract`, `must_mention_flow_steps`, `must_mention_ordered_steps`, `must_mention_calls`, `must_mention_sequence_edges`, `fact_gaps`. Ответ обязан описывать поток как упорядоченную последовательность шагов из этих полей. Не заявляй полноту потока, если в fact_gaps указано иное. Не делай неподтверждённых утверждений.
Проследи поток выполнения или поток данных по найденным артефактам.
Строй ответ вокруг `must_mention_flow_steps`, `must_mention_calls` и `must_mention_sequence_edges` из payload.
Старайся описывать шаги последовательно и коротко, без лишних подзаголовков: сначала, затем, после этого, в конце.
Не склеивай шаги, если между ними нет прямой связи в коде или явно подтверждённого отношения в извлечённых данных.
Если поток восстанавливается только частично, так и скажи, опираясь на `fact_gaps`, и не заявляй, что flow восстановлен полностью.
Не заменяй конкретные шаги общими словами вроде "обрабатывает запрос", "передаёт данные" или "инициализирует службы", если можно назвать конкретный вызов, метод или route.
Не используй сильные формулировки вроде "полностью восстанавливается", "полный поток виден", если payload показывает только часть цепочки.
Отвечай только по коду из контекста. Пиши естественным языком, без лишних markdown-секций.
Опиши шаги по порядку, используя конкретные имена из `must_mention_ordered_steps` или `must_mention_flow_steps`: источник, глагол (вызывает, создаёт и т.д.), цель. Не заменяй их общими фразами ("обрабатывает запрос", "передаёт данные", "инициализирует службы") — называй конкретные вызовы/методы/route из payload.
Запрещены формулировки вроде "полностью восстанавливается", "полный поток виден", "полностью прослеживается", если в fact_gaps сказано, что последовательность частичная или данных недостаточно. Если поток частичный — явно скажи об этом в конце.
Не склеивай шаги без прямой связи в payload. Не добавляй шаги, которых нет в must_mention_*.
docs_explain_answer: |
Ты объясняешь документацию системы.
На вход приходит JSON с полями:
- question
- intent
- sub_intent
- documents
- facts
- relations
Правила:
- Используй только предоставленные факты
- Не додумывай
- Если данных недостаточно, скажи это явно
- Объясняй структурировано
Формат ответа:
1. Краткое описание
2. Основные элементы
3. Как это работает
4. Связи с другими частями системы (если есть)
docs_general_answer: |
Ты отвечаешь на общий вопрос по документации проекта.
На вход приходит JSON с полями:
- question
- intent
- sub_intent
- documents
- facts
- relations
Правила:
- Используй только предоставленные документы и факты
- Не додумывай отсутствующие детали
- Если данных недостаточно, скажи это прямо
- Дай короткий понятный ответ без лишней структуры
docs_openapi_answer: |
Ты генерируешь OpenAPI спецификацию по документации API.
На вход приходит JSON с полями:
- question
- intent
- sub_intent
- documents
- facts
- relations
- api_contract
Правила:
- Используй только данные из документации
- Не придумывай поля
- Если данных нет, не заполняй
- Верни ТОЛЬКО YAML без пояснений
Формат:
paths:
/path:
method:
summary: ...
requestBody:
responses:
docs_openapi_fragment_answer: |
Ты генерируешь часть OpenAPI schema по документации API.
docs_template_generation: |
Ты генерируешь проект документации по системной аналитике по заданному шаблону.
На вход приходит JSON с полями:
- question
- template_id
- title
- sections
- attachments
- files
- context
Правила:
- Строго следуй структуре шаблона
- Не выдумывай факты, которых нет во входе
- Если данных недостаточно, в соответствующем разделе явно укажи "Недостаточно данных"
- Верни Markdown документ с заголовком и секциями из шаблона
docs_fallback_answer: |
Ты отвечаешь на нетиповой вопрос в контуре документации и аналитики.
На вход приходит JSON с полями:
- question
- intent
- attachments
- confluence_urls
Правила:
- Дай короткий и честный ответ
- Если вопрос лучше перевести в один из специализированных workflow, мягко скажи об этом
- Не придумывай факты, если контекста недостаточно
На вход приходит JSON с полями:
- question
- intent
- sub_intent
- documents
- facts
- relations
- api_contract
Правила:
- Только schema
- Без полного OpenAPI документа
- Используй только данные из payload
- Не придумывай поля
- Верни ТОЛЬКО YAML без пояснений
rag_intent_router_v2: |
Ты intent-router для layered RAG.
На вход ты получаешь JSON с полями:
@@ -261,20 +312,75 @@ prompts:
- last_query: предыдущий запрос пользователя
- allowed_intents: допустимые intent'ы
Выбери ровно один intent из allowed_intents.
Выбери ровно один intent из allowed_intents и один подходящий sub_intent.
Верни только JSON без markdown и пояснений.
Строгий формат ответа:
{"intent":"<one_of_allowed_intents>","confidence":<number_0_to_1>,"reason":"<short_reason>"}
{"intent":"<one_of_allowed_intents>","sub_intent":"<matching_sub_intent_or_empty_string>","confidence":<number_0_to_1>}
Правила:
- CODE_QA: объяснение по коду, архитектуре, классам, методам, файлам, блокам кода, поведению приложения по реализации.
- DOCS_QA: объяснение по документации, README, markdown, specs, runbooks, разделам документации.
- DOCUMENTATION_EXPLAIN: объяснение сущности, компонента, API метода, flow или связанных документов по документации.
- OPENAPI_GENERATION: генерация OpenAPI/Swagger/YAML/spec/schema по документации API.
- GENERAL_QA: слишком общий или нечеткий вопрос по документации/проекту, который нельзя уверенно отнести к explain/openapi.
- GENERATE_DOCS_FROM_CODE: просьба сгенерировать, подготовить или обновить документацию по коду.
- PROJECT_MISC: прочие вопросы по проекту, не относящиеся явно к коду или документации.
Допустимые docs sub-intents:
- SYSTEM_FLOW_EXPLAIN
- COMPONENT_EXPLAIN
- API_METHOD_EXPLAIN
- ENTITY_EXPLAIN
- RELATED_DOCS_EXPLAIN
- OPENAPI_METHOD_GENERATE
- OPENAPI_FRAGMENT_GENERATE
- GENERIC_QA
Приоритет:
- Если пользователь просит именно подготовить документацию по коду, выбирай GENERATE_DOCS_FROM_CODE.
- Если есть openapi, swagger, yaml, schema или spec, выбирай OPENAPI_GENERATION.
Если запрос про request, response или schema, выбирай OPENAPI_FRAGMENT_GENERATE.
Иначе выбирай OPENAPI_METHOD_GENERATE.
- Если запрос про связанные документы, где еще описано, что еще посмотреть, какие страницы связаны, какие документы по теме, выбирай DOCUMENTATION_EXPLAIN и sub_intent RELATED_DOCS_EXPLAIN.
- Если есть объясни, как работает, что делает или что такое по документации, выбирай DOCUMENTATION_EXPLAIN.
Для API/endpoint/method выбирай API_METHOD_EXPLAIN.
Для flow/workflow/process выбирай SYSTEM_FLOW_EXPLAIN.
Для entity/сущности выбирай ENTITY_EXPLAIN.
Иначе выбирай COMPONENT_EXPLAIN.
- Если пользователь спрашивает про конкретный класс, файл, метод или блок кода, выбирай CODE_QA.
- Если пользователь спрашивает про README, docs, markdown или конкретную документацию, выбирай DOCS_QA.
- Если сигнал неочевиден, выбирай PROJECT_MISC и confidence <= 0.6.
- Если пользователь спрашивает про README, docs, markdown или конкретную документацию без явного openapi, выбирай DOCUMENTATION_EXPLAIN.
- Если сигнал неочевиден, выбирай GENERAL_QA и confidence <= 0.6.
rag_docs_router_disambiguation_v1: |
Ты помогаешь разрешить неоднозначность в docs sub-intent router.
На вход приходит JSON:
- query
- normalized_query
- deterministic_primary_candidate
- deterministic_candidates
- ambiguity_reason
- matched_anchor_type
- query_entity_candidates
- query_anchor_candidates
Выбери ровно один sub_intent только из списка deterministic_candidates.
Не придумывай новые labels.
Допустимые labels:
- SYSTEM_FLOW_EXPLAIN
- COMPONENT_EXPLAIN
- API_METHOD_EXPLAIN
- ENTITY_EXPLAIN
- RELATED_DOCS_EXPLAIN
- GENERIC_QA
- OPENAPI_METHOD_GENERATE
- OPENAPI_FRAGMENT_GENERATE
Правила:
- Если запрос обзорный, про структуру документации, "что есть", "с чего начать", выбирай GENERIC_QA.
- Если запрос про runtime health, статус воркера, состояние runtime как понятие/сущность, выбирай ENTITY_EXPLAIN.
- Если запрос про процесс, health check flow, последовательность шагов, выбирай SYSTEM_FLOW_EXPLAIN.
- Если есть точный endpoint/path anchor, не уводи запрос в GENERIC_QA.
- Если есть CamelCase component-like token, предпочитай COMPONENT_EXPLAIN.
Верни только JSON:
{"sub_intent":"<one_of_candidates>","reason":"<short_reason>","confidence":"low|medium|high"}
@@ -1,6 +1,7 @@
"""Публичный API runtime: оркестрация роутинг → retrieval → evidence gate → генерация ответа."""
from app.modules.agent.runtime.executor import AgentRuntimeExecutor
from app.modules.agent.runtime.docs_qa_pipeline import DocsQAPipelineRunner
from app.modules.agent.runtime.models import (
RuntimeDraftAnswer,
RuntimeExecutionState,
@@ -11,6 +12,7 @@ from app.modules.agent.runtime.steps.retrieval import RuntimeRepoContextFactory,
__all__ = [
"AgentRuntimeExecutor",
"DocsQAPipelineRunner",
"RuntimeDraftAnswer",
"RuntimeExecutionState",
"RuntimeFinalResult",
@@ -0,0 +1,9 @@
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsDiagnostics, DocsQAPipelineResult, OpenAPIResult
from app.modules.agent.runtime.docs_qa_pipeline.pipeline import DocsQAPipelineRunner
__all__ = [
"DocsDiagnostics",
"DocsQAPipelineRunner",
"DocsQAPipelineResult",
"OpenAPIResult",
]
@@ -0,0 +1,56 @@
from __future__ import annotations
from app.modules.agent.runtime.docs_qa_pipeline.doc_identity import DocsCanonicalDocIdResolver
class DocsAnchorSelector:
def __init__(self, resolver: DocsCanonicalDocIdResolver | None = None) -> None:
self._resolver = resolver or DocsCanonicalDocIdResolver()
def select(self, *, sub_intent: str, anchor_type: str, anchor_value: str | None, rows: list[dict]) -> dict[str, object]:
if sub_intent != "RELATED_DOCS_EXPLAIN":
return {"anchor_candidates": [], "selected_anchor": None, "anchor_selection_reason": "", "anchor_match_type": ""}
scored = self._scored_candidates(anchor_type=anchor_type, anchor_value=anchor_value, rows=rows)
if not scored:
return {"anchor_candidates": [], "selected_anchor": None, "anchor_selection_reason": "", "anchor_match_type": ""}
selected = scored[0]
return {
"anchor_candidates": [item["doc_id"] for item in scored],
"selected_anchor": selected["doc_id"],
"anchor_selection_reason": selected["reason"],
"anchor_match_type": selected["match_type"],
}
def _scored_candidates(self, *, anchor_type: str, anchor_value: str | None, rows: list[dict]) -> list[dict[str, object]]:
value = str(anchor_value or "").strip().lower()
endpoint_slug = value.strip("/").replace("/", "_").replace("-", "_")
scored: list[dict[str, object]] = []
for row in rows:
metadata = dict(row.get("metadata") or {})
for doc_id in self._resolver.candidates(row):
score = 0
match_type = "semantic_fallback"
reason = "relation_neighbor"
if anchor_type == "endpoint" and value:
if str(metadata.get("endpoint") or "").strip().lower() == value:
score, match_type, reason = 100, "exact_path", "metadata.endpoint exact match"
elif endpoint_slug and endpoint_slug in doc_id.lower():
score, match_type, reason = 90, "exact_path", "doc_id matches requested endpoint slug"
elif endpoint_slug and endpoint_slug in str(row.get("path") or "").lower().replace("-", "_"):
score, match_type, reason = 85, "exact_path", "path matches requested endpoint slug"
elif anchor_type == "entity" and value:
entity_value = str(metadata.get("entity") or "").strip().lower()
if entity_value == value:
score, match_type, reason = 100, "exact_entity", "metadata.entity exact match"
elif value in doc_id.lower():
score, match_type, reason = 90, "exact_title", "doc_id matches requested entity"
if score == 0:
score = 40 if str(row.get("layer") or "") == "D5_RELATION_GRAPH" else 20
scored.append({"doc_id": doc_id, "score": score, "match_type": match_type, "reason": reason})
unique: dict[str, dict[str, object]] = {}
for item in scored:
doc_id = str(item["doc_id"])
current = unique.get(doc_id)
if current is None or int(item["score"]) > int(current["score"]):
unique[doc_id] = item
return sorted(unique.values(), key=lambda item: (-int(item["score"]), str(item["doc_id"])))
@@ -0,0 +1,43 @@
from __future__ import annotations
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsEvidenceBundle
class DocsAnswerSynthesizer:
def synthesize(self, query: str, evidence_bundle: DocsEvidenceBundle) -> str:
mode = "graph_summary" if evidence_bundle.sub_intent == "RELATED_DOCS_EXPLAIN" else "prose"
if mode == "list":
return self._list_answer(evidence_bundle)
if mode == "graph_summary":
return self._graph_summary(evidence_bundle)
return self._prose_answer(query, evidence_bundle)
def _prose_answer(self, query: str, bundle: DocsEvidenceBundle) -> str:
parts = [item["content"] for item in bundle.facts[:3] if item.get("content")]
if not parts:
parts = [item["content"] for item in bundle.entities[:2] if item.get("content")]
if not parts:
parts = [item["content"] for item in bundle.workflows[:2] if item.get("content")]
if not parts:
parts = [item["content"] for item in bundle.documents[:2] if item.get("content")]
if not parts:
parts = [item["content"] for item in bundle.chunks[:2] if item.get("content")]
if not parts:
return f"Недостаточно данных в документации для ответа на запрос: {query}"
return "\n".join(parts)
def _list_answer(self, bundle: DocsEvidenceBundle) -> str:
items = [item["title"] or item["path"] for item in bundle.documents[:5]]
if not items:
items = [item["title"] or item["path"] for item in bundle.facts[:5]]
if not items:
return "Подходящие документы не найдены."
return "\n".join(f"- {item}" for item in items)
def _graph_summary(self, bundle: DocsEvidenceBundle) -> str:
items = [item["title"] or item["content"] for item in bundle.relations[:5]]
if not items:
items = [item["title"] or item["path"] for item in bundle.documents[:5]]
if not items:
return "Связанные документы не найдены."
return "\n".join(f"- {item}" for item in items)
@@ -0,0 +1,259 @@
from __future__ import annotations
from app.modules.agent.runtime.docs_qa_pipeline.doc_identity import DocsCanonicalDocIdResolver
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsDiagnostics, DocsEvidenceBundle, OpenAPIResult
class DocsDiagnosticsBuilder:
def __init__(self, resolver: DocsCanonicalDocIdResolver | None = None) -> None:
self._resolver = resolver or DocsCanonicalDocIdResolver()
def build(
self,
*,
intent: str,
sub_intent: str,
planned_layers: list[str],
executed_layers: list[str],
non_empty_layers: list[str],
layer_diagnostics: dict[str, object],
evidence_bundle: DocsEvidenceBundle,
openapi_result: OpenAPIResult | None,
prompt_used: str,
llm_mode: str,
answer_mode: str,
output_valid: bool,
matched_intent_source: str,
matched_anchor_type: str,
matched_anchor_value: str | None,
exact_anchor_match: bool,
query_entity_candidates: list[str],
resolved_entity_candidates: list[str],
query_anchor_candidates: list[str],
resolved_anchor_candidates: list[str],
anchor_candidates: list[str],
selected_anchor: str | None,
anchor_selection_reason: str,
anchor_match_type: str,
docs_layers_with_hits: list[str],
gate_decision: str,
gate_decision_reason: str,
gate_missing_requirements: list[str],
gate_satisfied_requirements: list[str],
requested_fragment_type: str | None,
fragment_evidence_found: list[str],
fragment_missing_requirements: list[str],
prompt: dict[str, object],
degraded_reason: str | None,
fallback_used: bool,
code_intents_stubbed: bool,
) -> DocsDiagnostics:
missing = list((openapi_result.diagnostics if openapi_result else {}).get("missing_required_fields") or [])
openapi_fields = 0
if openapi_result is not None:
openapi_fields += len((openapi_result.request_schema or {}).get("properties") or {})
openapi_fields += len((openapi_result.response_schema or {}).get("properties") or {})
return DocsDiagnostics(
intent=intent,
sub_intent=sub_intent,
layers_used=list(planned_layers),
documents_found=len(evidence_bundle.documents),
facts_found=len(evidence_bundle.facts),
relations_found=len(evidence_bundle.relations),
openapi_fields_extracted=openapi_fields,
missing_required_fields=missing,
openapi_status=self._openapi_status(openapi_result),
prompt_used=prompt_used,
llm_mode=llm_mode,
output_valid=output_valid,
matched_intent_source=matched_intent_source,
matched_anchor_type=matched_anchor_type,
matched_anchor_value=matched_anchor_value,
exact_anchor_match=exact_anchor_match,
docs_layers_requested=list(planned_layers),
docs_layers_with_hits=list(docs_layers_with_hits),
planned_layers=list(planned_layers),
executed_layers=list(executed_layers),
non_empty_layers=list(non_empty_layers),
layer_diagnostics=dict(layer_diagnostics),
query_entity_candidates=list(query_entity_candidates),
resolved_entity_candidates=list(resolved_entity_candidates),
query_anchor_candidates=list(query_anchor_candidates),
resolved_anchor_candidates=list(resolved_anchor_candidates),
anchor_candidates=list(anchor_candidates),
selected_anchor=selected_anchor,
anchor_selection_reason=anchor_selection_reason,
anchor_match_type=anchor_match_type,
doc_ids=self._doc_ids(evidence_bundle, selected_anchor),
doc_paths=self._doc_paths(evidence_bundle),
doc_titles=self._doc_titles(evidence_bundle),
relation_hits_count=len(evidence_bundle.relations),
relation_targets=self._relation_targets(evidence_bundle),
selected_doc_ids=self._selected_doc_ids(evidence_bundle),
selected_fact_ids=self._selected_ids(evidence_bundle.facts, ("fact_id", "doc_id", "document_id"), fallback="path"),
selected_relation_ids=self._selected_ids(
evidence_bundle.relations,
("relation_id", "target_doc_id", "target_document_id", "doc_id", "document_id"),
fallback="path",
),
selected_chunk_ids=self._selected_ids(evidence_bundle.chunks, ("chunk_id", "doc_id", "document_id"), fallback="path"),
selected_entity_ids=self._selected_ids(evidence_bundle.entities, ("entity", "doc_id", "document_id"), fallback="title"),
selected_workflow_ids=self._selected_ids(evidence_bundle.workflows, ("workflow_id", "doc_id", "document_id"), fallback="path"),
fallback_doc_hits_count=len(evidence_bundle.documents) + len(evidence_bundle.chunks),
fallback_used=fallback_used,
fact_hits=len(evidence_bundle.facts),
entity_hits=self._entity_hits(evidence_bundle),
evidence_summary=self._evidence_summary(evidence_bundle, openapi_result),
gate_decision=gate_decision,
gate_decision_reason=gate_decision_reason,
gate_missing_requirements=list(gate_missing_requirements),
gate_satisfied_requirements=list(gate_satisfied_requirements),
openapi_evidence=dict(self._openapi_evidence(openapi_result)),
requested_fragment_type=requested_fragment_type,
fragment_evidence_found=list(fragment_evidence_found),
fragment_missing_requirements=list(fragment_missing_requirements),
prompt=dict(prompt),
answer_mode=answer_mode,
degrade_reason=degraded_reason,
degraded_reason=degraded_reason,
code_intents_stubbed=code_intents_stubbed,
)
def _openapi_status(self, openapi_result: OpenAPIResult | None) -> dict[str, bool]:
diagnostics = openapi_result.diagnostics if openapi_result else {}
return {
"has_path": bool(diagnostics.get("has_path")),
"has_method": bool(diagnostics.get("has_method")),
"has_request": bool(diagnostics.get("has_request")),
"has_response": bool(diagnostics.get("has_response")),
}
def _doc_ids(self, evidence_bundle: DocsEvidenceBundle, selected_anchor: str | None) -> list[str]:
values = self._collect_doc_ids(evidence_bundle)
for item in evidence_bundle.relations:
metadata = dict(item.get("metadata") or {})
target = metadata.get("target_doc_id") or metadata.get("target_document_id")
if target:
values.append(str(target))
result = self._dedupe(values)
if selected_anchor and selected_anchor in result:
return [selected_anchor, *[item for item in result if item != selected_anchor]]
if selected_anchor:
return [selected_anchor, *result]
return result
def _selected_doc_ids(self, evidence_bundle: DocsEvidenceBundle) -> list[str]:
values = self._selected_ids(evidence_bundle.documents, ("doc_id", "document_id"), fallback="path")
values.extend(self._selected_ids(evidence_bundle.entities, ("doc_id", "document_id"), fallback="path"))
values.extend(self._selected_ids(evidence_bundle.workflows, ("doc_id", "document_id"), fallback="path"))
return self._dedupe(values)
def _doc_paths(self, evidence_bundle: DocsEvidenceBundle) -> list[str]:
return self._collect_distinct(evidence_bundle, "path")
def _doc_titles(self, evidence_bundle: DocsEvidenceBundle) -> list[str]:
return self._collect_distinct(evidence_bundle, "title")
def _relation_targets(self, evidence_bundle: DocsEvidenceBundle) -> list[str]:
values: list[str] = []
for item in evidence_bundle.relations:
metadata = dict(item.get("metadata") or {})
target = (
metadata.get("target_doc_id")
or metadata.get("target_document_id")
or metadata.get("related_to")
or metadata.get("document_id")
or metadata.get("doc_id")
or item.get("title")
or item.get("path")
)
if target:
values.append(str(target))
return self._dedupe(values)
def _selected_ids(self, items: list[dict], metadata_keys: tuple[str, ...], *, fallback: str) -> list[str]:
values: list[str] = []
for item in items:
metadata = dict(item.get("metadata") or {})
candidate = None
for key in metadata_keys:
candidate = metadata.get(key)
if candidate:
break
if not candidate:
candidate = item.get(fallback)
if candidate:
values.append(str(candidate))
return self._dedupe(values)
def _collect_doc_ids(self, evidence_bundle: DocsEvidenceBundle) -> list[str]:
values: list[str] = []
for item in evidence_bundle.raw_rows:
values.extend(self._resolver.candidates(item))
return self._dedupe(values)
def _collect_distinct(self, evidence_bundle: DocsEvidenceBundle, key: str, *, fallback_key: str | None = None) -> list[str]:
values: list[str] = []
for item in evidence_bundle.raw_rows:
metadata = dict(item.get("metadata") or {})
candidate = metadata.get(key) if key == "doc_id" else item.get(key)
if not candidate and fallback_key:
candidate = item.get(fallback_key)
if candidate:
values.append(str(candidate))
return self._dedupe(values)
def _dedupe(self, values: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
normalized = value.strip()
if not normalized:
continue
if normalized in seen:
continue
seen.add(normalized)
result.append(normalized)
return result
def _entity_hits(self, evidence_bundle: DocsEvidenceBundle) -> int:
values = self._collect_distinct(evidence_bundle, "entity")
return len(values)
def _evidence_summary(self, evidence_bundle: DocsEvidenceBundle, openapi_result: OpenAPIResult | None) -> dict[str, object]:
return {
"documents": len(evidence_bundle.documents),
"facts": len(evidence_bundle.facts),
"entities": len(evidence_bundle.entities),
"workflows": len(evidence_bundle.workflows),
"relations": len(evidence_bundle.relations),
"chunks": len(evidence_bundle.chunks),
"selected_doc_ids": self._selected_doc_ids(evidence_bundle),
"selected_fact_ids": self._selected_ids(evidence_bundle.facts, ("fact_id", "doc_id", "document_id"), fallback="path"),
"selected_relation_ids": self._selected_ids(
evidence_bundle.relations,
("relation_id", "target_doc_id", "target_document_id", "doc_id", "document_id"),
fallback="path",
),
"selected_chunk_ids": self._selected_ids(evidence_bundle.chunks, ("chunk_id", "doc_id", "document_id"), fallback="path"),
"entity_hits": self._entity_hits(evidence_bundle),
"openapi_signals": self._openapi_evidence(openapi_result),
}
def _openapi_evidence(self, openapi_result: OpenAPIResult | None) -> dict[str, bool]:
diagnostics = openapi_result.diagnostics if openapi_result else {}
return {
"path_found": bool(diagnostics.get("has_path")),
"method_found": bool(diagnostics.get("has_method")),
"operation_semantics_found": bool(diagnostics.get("operation_semantics_found")),
"request_payload_found": bool(diagnostics.get("request_payload_found")),
"request_schema": bool(diagnostics.get("has_request")),
"request_fields_found": bool(diagnostics.get("request_fields_found")),
"response_payload_found": bool(diagnostics.get("response_payload_found")),
"response_schema": bool(diagnostics.get("has_response")),
"response_fields_found": bool(diagnostics.get("response_fields_found")),
"status_codes": bool(diagnostics.get("status_codes_found")),
"content_type_found": bool(diagnostics.get("content_type_found")),
"examples_found": bool(diagnostics.get("examples_found")),
"payload_description": bool(diagnostics.get("payload_description_found")),
}
@@ -0,0 +1,47 @@
from __future__ import annotations
import re
class DocsCanonicalDocIdResolver:
_DOC_PATH_RE = re.compile(r"docs/(?:documentation/)?(?P<section>api|domain|logic|architecture)/(?P<name>[^/]+)\.md$", re.IGNORECASE)
_TITLE_ENDPOINT_RE = re.compile(r"/([a-z0-9_{}-]+)", re.IGNORECASE)
def candidates(self, row: dict) -> list[str]:
metadata = dict(row.get("metadata") or {})
values: list[str] = []
for candidate in (
metadata.get("document_id"),
metadata.get("doc_id"),
self.from_path(str(row.get("path") or "")),
self.from_title(str(row.get("title") or ""), str(row.get("path") or "")),
):
value = str(candidate or "").strip()
if value and value not in values:
values.append(value)
return values
def from_path(self, path: str) -> str | None:
match = self._DOC_PATH_RE.search((path or "").strip())
if match is None:
return None
section = match.group("section").lower()
name = match.group("name").lower().replace("-", "_")
if section == "api" and not name.endswith("_endpoint"):
name = f"{name}_endpoint"
if section == "domain" and name.endswith("_entity"):
name = name[: -len("_entity")]
return f"{section}.{name}"
def from_title(self, title: str, path: str) -> str | None:
normalized = (title or "").strip().lower()
if "/health" in normalized:
return "api.health_endpoint"
if "/send" in normalized:
return "api.send_message_endpoint"
match = self._TITLE_ENDPOINT_RE.search(normalized)
section = "api" if "/api/" in (path or "").lower() else ""
if match and section:
slug = match.group(1).replace("-", "_")
return f"{section}.{slug}_endpoint"
return None
@@ -0,0 +1,91 @@
from __future__ import annotations
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsEvidenceBundle
class DocsEvidenceBuilder:
_CAPS = {
"SYSTEM_FLOW_EXPLAIN": {"documents": 1, "facts": 1, "entities": 0, "workflows": 2, "relations": 2, "chunks": 2},
"COMPONENT_EXPLAIN": {"documents": 1, "facts": 3, "entities": 0, "workflows": 0, "relations": 2, "chunks": 2},
"API_METHOD_EXPLAIN": {"documents": 1, "facts": 3, "entities": 0, "workflows": 1, "relations": 0, "chunks": 2},
"ENTITY_EXPLAIN": {"documents": 1, "facts": 1, "entities": 2, "workflows": 0, "relations": 2, "chunks": 1},
"RELATED_DOCS_EXPLAIN": {"documents": 2, "facts": 0, "entities": 1, "workflows": 0, "relations": 4, "chunks": 1},
"GENERIC_QA": {"documents": 2, "facts": 0, "entities": 0, "workflows": 0, "relations": 0, "chunks": 1},
"OPENAPI_METHOD_GENERATE": {"documents": 1, "facts": 4, "entities": 0, "workflows": 0, "relations": 0, "chunks": 2},
"OPENAPI_FRAGMENT_GENERATE": {"documents": 1, "facts": 4, "entities": 0, "workflows": 0, "relations": 0, "chunks": 2},
}
_LAYER_TO_BUCKET = {
"D1_DOCUMENT_CATALOG": "documents",
"D2_FACT_INDEX": "facts",
"D3_ENTITY_CATALOG": "entities",
"D4_WORKFLOW_INDEX": "workflows",
"D5_RELATION_GRAPH": "relations",
"D0_DOC_CHUNKS": "chunks",
}
def build(self, *, intent: str, sub_intent: str, raw_rows: list[dict]) -> DocsEvidenceBundle:
buckets = {name: [] for name in self._LAYER_TO_BUCKET.values()}
for row in raw_rows:
bucket = self._LAYER_TO_BUCKET.get(str(row.get("layer") or ""))
if bucket is not None:
buckets[bucket].append(self._normalize_row(row))
support_paths = self._support_paths(sub_intent, buckets)
caps = self._CAPS.get(sub_intent, self._CAPS["GENERIC_QA"])
return DocsEvidenceBundle(
intent=intent,
sub_intent=sub_intent,
documents=buckets["documents"][: caps["documents"]],
facts=buckets["facts"][: caps["facts"]],
entities=buckets["entities"][: caps["entities"]],
workflows=buckets["workflows"][: caps["workflows"]],
relations=buckets["relations"][: caps["relations"]],
chunks=self._select_chunks(buckets["chunks"], support_paths, caps["chunks"]),
raw_rows=list(raw_rows),
)
def _normalize_row(self, row: dict) -> dict:
return {
"layer": str(row.get("layer") or ""),
"path": str(row.get("path") or ""),
"title": str(row.get("title") or ""),
"content": str(row.get("content") or ""),
"metadata": dict(row.get("metadata") or {}),
}
def _support_paths(self, sub_intent: str, buckets: dict[str, list[dict]]) -> list[str]:
ordered: list[dict] = []
if sub_intent == "SYSTEM_FLOW_EXPLAIN":
ordered = [*buckets["workflows"], *buckets["relations"], *buckets["documents"]]
elif sub_intent == "COMPONENT_EXPLAIN":
ordered = [*buckets["facts"], *buckets["relations"], *buckets["documents"]]
elif sub_intent == "API_METHOD_EXPLAIN":
ordered = [*buckets["facts"], *buckets["workflows"], *buckets["documents"]]
elif sub_intent == "ENTITY_EXPLAIN":
ordered = [*buckets["entities"], *buckets["relations"], *buckets["documents"]]
elif sub_intent == "RELATED_DOCS_EXPLAIN":
ordered = [*buckets["relations"], *buckets["documents"], *buckets["entities"]]
elif sub_intent == "GENERIC_QA":
ordered = [*buckets["documents"], *buckets["chunks"]]
else:
ordered = [*buckets["facts"], *buckets["documents"]]
paths: list[str] = []
for item in ordered:
path = str(item.get("path") or "").strip()
if path and path not in paths:
paths.append(path)
return paths[:4]
def _select_chunks(self, chunks: list[dict], support_paths: list[str], limit: int) -> list[dict]:
if limit <= 0:
return []
targeted = [item for item in chunks if str(item.get("path") or "") in support_paths]
selected = targeted[:limit]
if len(selected) >= limit:
return selected
for item in chunks:
if item in selected:
continue
selected.append(item)
if len(selected) >= limit:
break
return selected
@@ -0,0 +1,117 @@
from __future__ import annotations
import re
class DocsExactAnchorMatcher:
_TOKEN_RE = re.compile(r"[a-z0-9_./{}-]+", re.IGNORECASE)
_STRICT_ANCHOR_TYPES = {"endpoint", "entity"}
def filter_rows(self, rows: list[dict], *, anchor_type: str, anchor_value: str | None) -> tuple[list[dict], bool]:
if anchor_type == "none" or not anchor_value or anchor_type not in self._STRICT_ANCHOR_TYPES:
return list(rows), False
exact = [row for row in rows if self._matches(row, anchor_type=anchor_type, anchor_value=anchor_value)]
if exact:
return self._expand_support_rows(rows, exact), True
return [], False
def resolved_entity_candidates(self, rows: list[dict]) -> list[str]:
values: list[str] = []
for row in rows:
metadata = dict(row.get("metadata") or {})
for key in ("entity", "component"):
candidate = metadata.get(key)
if candidate:
values.append(str(candidate))
return self._dedupe(values)
def resolved_anchor_candidates(self, rows: list[dict]) -> list[str]:
values: list[str] = []
for row in rows:
metadata = dict(row.get("metadata") or {})
for candidate in (
metadata.get("endpoint"),
metadata.get("document_id"),
metadata.get("doc_id"),
metadata.get("entity"),
metadata.get("component"),
row.get("path"),
):
if candidate:
values.append(str(candidate))
return self._dedupe(values)
def _matches(self, row: dict, *, anchor_type: str, anchor_value: str) -> bool:
haystacks = [
str(row.get("path") or "").lower(),
str(row.get("title") or "").lower(),
str(row.get("content") or "").lower(),
str(dict(row.get("metadata") or {}).get("endpoint") or "").lower(),
str(dict(row.get("metadata") or {}).get("entity") or "").lower(),
str(dict(row.get("metadata") or {}).get("component") or "").lower(),
str(dict(row.get("metadata") or {}).get("document_id") or "").lower(),
str(dict(row.get("metadata") or {}).get("doc_id") or "").lower(),
]
needle = anchor_value.lower().strip()
if anchor_type == "endpoint":
return any(needle == value or f" {needle}" in f" {value}" for value in haystacks if value)
return any(needle in value for value in haystacks if value)
def _expand_support_rows(self, rows: list[dict], exact: list[dict]) -> list[dict]:
doc_ids = self._doc_ids(exact)
paths = {str(row.get("path") or "").strip() for row in exact if str(row.get("path") or "").strip()}
expanded = list(exact)
for row in rows:
metadata = dict(row.get("metadata") or {})
row_doc_ids = {
str(metadata.get("document_id") or "").strip(),
str(metadata.get("doc_id") or "").strip(),
}
row_path = str(row.get("path") or "").strip()
if row_path and row_path in paths:
expanded.append(row)
continue
if any(candidate and candidate in doc_ids for candidate in row_doc_ids):
expanded.append(row)
return self._dedupe_rows(expanded)
def _doc_ids(self, rows: list[dict]) -> set[str]:
values: set[str] = set()
for row in rows:
metadata = dict(row.get("metadata") or {})
for candidate in (metadata.get("document_id"), metadata.get("doc_id")):
value = str(candidate or "").strip()
if value:
values.add(value)
return values
def _dedupe_rows(self, rows: list[dict]) -> list[dict]:
result: list[dict] = []
seen: set[tuple[str, str, str, int | None, int | None]] = set()
for row in rows:
key = (
str(row.get("layer") or ""),
str(row.get("path") or ""),
str(row.get("title") or ""),
row.get("span_start"),
row.get("span_end"),
)
if key in seen:
continue
seen.add(key)
result.append(row)
return result
def _dedupe(self, values: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
normalized = value.strip()
if not normalized:
continue
key = normalized.lower()
if key in seen:
continue
seen.add(key)
result.append(normalized)
return result
@@ -0,0 +1,114 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class OpenAPIResult(BaseModel):
model_config = ConfigDict(extra="forbid")
path: str = ""
method: str = ""
request_schema: dict[str, Any] | None = None
response_schema: dict[str, Any] | None = None
raw_yaml: str = ""
diagnostics: dict[str, Any] = Field(default_factory=dict)
class DocsDiagnostics(BaseModel):
model_config = ConfigDict(extra="forbid")
intent: str
sub_intent: str
layers_used: list[str] = Field(default_factory=list)
documents_found: int = 0
facts_found: int = 0
relations_found: int = 0
openapi_fields_extracted: int = 0
missing_required_fields: list[str] = Field(default_factory=list)
openapi_status: dict[str, bool] = Field(default_factory=dict)
prompt_used: str = ""
llm_mode: str = "prose"
output_valid: bool = True
matched_intent_source: str = "deterministic"
matched_anchor_type: str = "none"
matched_anchor_value: str | None = None
exact_anchor_match: bool = False
docs_layers_requested: list[str] = Field(default_factory=list)
docs_layers_with_hits: list[str] = Field(default_factory=list)
planned_layers: list[str] = Field(default_factory=list)
executed_layers: list[str] = Field(default_factory=list)
non_empty_layers: list[str] = Field(default_factory=list)
layer_diagnostics: dict[str, Any] = Field(default_factory=dict)
query_entity_candidates: list[str] = Field(default_factory=list)
resolved_entity_candidates: list[str] = Field(default_factory=list)
query_anchor_candidates: list[str] = Field(default_factory=list)
resolved_anchor_candidates: list[str] = Field(default_factory=list)
anchor_candidates: list[str] = Field(default_factory=list)
selected_anchor: str | None = None
anchor_selection_reason: str = ""
anchor_match_type: str = ""
doc_ids: list[str] = Field(default_factory=list)
doc_paths: list[str] = Field(default_factory=list)
doc_titles: list[str] = Field(default_factory=list)
relation_hits_count: int = 0
relation_targets: list[str] = Field(default_factory=list)
selected_doc_ids: list[str] = Field(default_factory=list)
selected_fact_ids: list[str] = Field(default_factory=list)
selected_relation_ids: list[str] = Field(default_factory=list)
selected_chunk_ids: list[str] = Field(default_factory=list)
selected_entity_ids: list[str] = Field(default_factory=list)
selected_workflow_ids: list[str] = Field(default_factory=list)
fallback_doc_hits_count: int = 0
fallback_used: bool = False
fact_hits: int = 0
entity_hits: int = 0
evidence_summary: dict[str, Any] = Field(default_factory=dict)
gate_decision: str = "ready"
gate_decision_reason: str = ""
gate_missing_requirements: list[str] = Field(default_factory=list)
gate_satisfied_requirements: list[str] = Field(default_factory=list)
openapi_evidence: dict[str, bool] = Field(default_factory=dict)
requested_fragment_type: str | None = None
fragment_evidence_found: list[str] = Field(default_factory=list)
fragment_missing_requirements: list[str] = Field(default_factory=list)
prompt: dict[str, Any] = Field(default_factory=dict)
answer_mode: str = "answered"
degrade_reason: str | None = None
degraded_reason: str | None = None
code_intents_stubbed: bool = False
@dataclass(slots=True)
class DocsEvidenceBundle:
intent: str
sub_intent: str
documents: list[dict[str, Any]] = field(default_factory=list)
facts: list[dict[str, Any]] = field(default_factory=list)
entities: list[dict[str, Any]] = field(default_factory=list)
workflows: list[dict[str, Any]] = field(default_factory=list)
relations: list[dict[str, Any]] = field(default_factory=list)
chunks: list[dict[str, Any]] = field(default_factory=list)
raw_rows: list[dict[str, Any]] = field(default_factory=list)
@dataclass(slots=True)
class DocsQAPipelineResult:
user_query: str
rag_session_id: str
router_result: Any
retrieval_request: Any
evidence_bundle: DocsEvidenceBundle
answer: str
diagnostics: DocsDiagnostics
openapi_result: OpenAPIResult | None = None
prompt_name: str = ""
llm_request: dict[str, Any] = field(default_factory=dict)
output_valid: bool = True
answer_mode: str = "answered"
degraded_reason: str = ""
raw_rows: list[dict[str, Any]] = field(default_factory=list)
timings_ms: dict[str, int] = field(default_factory=dict)
mode: str = "full"
@@ -0,0 +1,187 @@
from __future__ import annotations
import re
from typing import Any
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsEvidenceBundle
class OpenAPIEvidenceExtractor:
_PATH_RE = re.compile(r"(/[a-z0-9_./{}-]+)", re.IGNORECASE)
_METHOD_RE = re.compile(r"\b(get|post|put|patch|delete)\b", re.IGNORECASE)
_FIELD_RE = re.compile(r"\b([a-z_][a-z0-9_]{1,40})\b")
_STATUS_RE = re.compile(r"\b(200|201|202|204|400|401|403|404|409|422|500|503)\b")
_FIELD_STOPWORDS = {
"request",
"response",
"schema",
"payload",
"fields",
"contains",
"type",
"properties",
"post",
"get",
"put",
"patch",
"delete",
"endpoint",
"returns",
"return",
"with",
"and",
"for",
"send",
"health",
"status",
"result",
"body",
}
def extract(self, bundle: DocsEvidenceBundle, *, requested_fragment_type: str | None) -> dict[str, Any]:
items = [*bundle.facts, *bundle.documents, *bundle.chunks]
path = self._extract_path(items)
method = self._extract_method(items)
request_schema = self._extract_schema(items, "request")
response_schema = self._extract_schema(items, "response")
if request_schema is None and requested_fragment_type == "request_schema":
request_schema = self._schema_from_text(items, kind="request")
if response_schema is None and requested_fragment_type == "response_schema":
response_schema = self._schema_from_text(items, kind="response")
if request_schema is None and response_schema is None and requested_fragment_type == "schema_fragment":
request_schema = self._schema_from_text(items, kind="schema")
status_codes = self._status_codes(items)
content_type = self._content_type(items)
operation_summary = self._operation_summary(items)
examples_found = self._examples_found(items)
diagnostics = {
"has_path": bool(path),
"has_method": bool(method),
"has_request": request_schema is not None,
"has_response": response_schema is not None,
"operation_semantics_found": bool(operation_summary),
"request_payload_found": request_schema is not None or self._has_payload_text(items, "request"),
"request_fields_found": self._field_count(request_schema) > 0,
"response_payload_found": response_schema is not None or self._has_payload_text(items, "response"),
"response_fields_found": self._field_count(response_schema) > 0,
"status_codes_found": bool(status_codes),
"content_type_found": bool(content_type),
"examples_found": examples_found,
"payload_description_found": self._has_payload_description(items),
"status_codes": status_codes,
"content_type": content_type,
"operation_summary": operation_summary,
}
return {
"path": path,
"method": method,
"request_schema": request_schema,
"response_schema": response_schema,
"diagnostics": diagnostics,
}
def _extract_path(self, items: list[dict[str, Any]]) -> str:
for item in items:
metadata = dict(item.get("metadata") or {})
if metadata.get("endpoint"):
return str(metadata["endpoint"])
for source in (item.get("title"), item.get("content")):
match = self._PATH_RE.search(str(source or ""))
if match:
return match.group(1)
return ""
def _extract_method(self, items: list[dict[str, Any]]) -> str:
for item in items:
metadata = dict(item.get("metadata") or {})
if metadata.get("http_method"):
return str(metadata["http_method"]).lower()
for source in (item.get("title"), item.get("content")):
match = self._METHOD_RE.search(str(source or ""))
if match:
return match.group(1).lower()
return ""
def _extract_schema(self, items: list[dict[str, Any]], kind: str) -> dict[str, Any] | None:
for item in items:
metadata = dict(item.get("metadata") or {})
candidate = metadata.get(f"{kind}_schema") or metadata.get(f"{kind}_fields")
schema = self._as_schema(candidate)
if schema is not None:
return schema
return None
def _schema_from_text(self, items: list[dict[str, Any]], *, kind: str) -> dict[str, Any] | None:
markers = {
"request": ("request", "payload", "body", "fields", "message", "chat_id"),
"response": ("response", "returns", "result", "status", "body"),
"schema": ("schema", "payload", "fields", "properties"),
}
fields: list[str] = []
for item in items:
text = " ".join((str(item.get("title") or ""), str(item.get("content") or ""))).lower()
if not any(marker in text for marker in markers[kind]):
continue
for token in self._FIELD_RE.findall(text):
if token in self._FIELD_STOPWORDS:
continue
if token not in fields:
fields.append(token)
if not fields:
return None
properties = {name: {"type": "string"} for name in fields[:8]}
required = [name for name in fields[:3] if len(name) > 1]
result: dict[str, Any] = {"type": "object", "properties": properties}
if required:
result["required"] = required
return result
def _as_schema(self, value: Any) -> dict[str, Any] | None:
if isinstance(value, dict):
return value
if isinstance(value, list):
properties = {str(item): {"type": "string"} for item in value if str(item).strip()}
return {"type": "object", "properties": properties} if properties else None
return None
def _status_codes(self, items: list[dict[str, Any]]) -> list[str]:
values: list[str] = []
for item in items:
text = " ".join((str(item.get("title") or ""), str(item.get("content") or "")))
for code in self._STATUS_RE.findall(text):
if code not in values:
values.append(code)
return values
def _content_type(self, items: list[dict[str, Any]]) -> str:
for item in items:
text = " ".join((str(item.get("title") or ""), str(item.get("content") or ""))).lower()
for marker in ("application/json", "json", "multipart/form-data", "x-www-form-urlencoded"):
if marker in text:
return marker
return ""
def _operation_summary(self, items: list[dict[str, Any]]) -> str:
for item in items:
for source in (item.get("title"), item.get("content")):
text = str(source or "").strip()
if len(text) >= 8:
return text[:120]
return ""
def _examples_found(self, items: list[dict[str, Any]]) -> bool:
for item in items:
text = " ".join((str(item.get("title") or ""), str(item.get("content") or ""))).lower()
if any(marker in text for marker in ("example", "пример", "{", "}")):
return True
return False
def _has_payload_text(self, items: list[dict[str, Any]], kind: str) -> bool:
markers = ("payload", "body", "fields", "request") if kind == "request" else ("response", "returns", "status", "body")
return any(any(marker in str(item.get("content") or "").lower() for marker in markers) for item in items)
def _has_payload_description(self, items: list[dict[str, Any]]) -> bool:
return any(len(str(item.get("content") or "").strip()) >= 12 for item in items)
def _field_count(self, schema: dict[str, Any] | None) -> int:
return len((schema or {}).get("properties") or {})
@@ -0,0 +1,161 @@
from __future__ import annotations
from typing import Any
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsEvidenceBundle, OpenAPIResult
from app.modules.agent.runtime.docs_qa_pipeline.openapi_evidence_extractor import OpenAPIEvidenceExtractor
class OpenAPIGenerator:
def __init__(self, extractor: OpenAPIEvidenceExtractor | None = None) -> None:
self._extractor = extractor or OpenAPIEvidenceExtractor()
def generate(
self,
evidence_bundle: DocsEvidenceBundle,
mode: str,
*,
requested_fragment_type: str | None = None,
) -> OpenAPIResult:
extracted = self._extractor.extract(evidence_bundle, requested_fragment_type=requested_fragment_type)
path = str(extracted["path"] or "")
method = str(extracted["method"] or "")
request_schema = extracted["request_schema"]
response_schema = extracted["response_schema"]
diagnostics = dict(extracted["diagnostics"] or {})
diagnostics["missing_required_fields"] = self._missing_fields(
path=path,
method=method,
request_schema=request_schema,
response_schema=response_schema,
mode=mode,
requested_fragment_type=requested_fragment_type,
diagnostics=diagnostics,
)
raw_yaml = self._render(
path=path,
method=method,
request_schema=request_schema,
response_schema=response_schema,
mode=mode,
requested_fragment_type=requested_fragment_type,
diagnostics=diagnostics,
)
return OpenAPIResult(
path=path,
method=method,
request_schema=request_schema,
response_schema=response_schema,
raw_yaml=raw_yaml,
diagnostics=diagnostics,
)
def _missing_fields(
self,
*,
path: str,
method: str,
request_schema: dict[str, Any] | None,
response_schema: dict[str, Any] | None,
mode: str,
requested_fragment_type: str | None,
diagnostics: dict[str, Any],
) -> list[str]:
missing: list[str] = []
if not path:
missing.append("path")
if mode == "OPENAPI_FRAGMENT_GENERATE":
if requested_fragment_type == "request_schema" and request_schema is None and not diagnostics.get("request_payload_found"):
missing.append("request_schema")
elif requested_fragment_type == "response_schema" and response_schema is None and not diagnostics.get("response_payload_found"):
missing.append("response_schema")
elif requested_fragment_type == "parameters" and not diagnostics.get("has_method"):
missing.append("parameters")
elif request_schema is None and response_schema is None and not diagnostics.get("payload_description_found"):
missing.append("schema_fragment")
return missing
if not method:
missing.append("method")
if request_schema is None and not diagnostics.get("request_payload_found"):
missing.append("request_schema")
if response_schema is None and not diagnostics.get("response_payload_found") and not diagnostics.get("status_codes_found"):
missing.append("response_schema")
return missing
def _render(
self,
*,
path: str,
method: str,
request_schema: dict[str, Any] | None,
response_schema: dict[str, Any] | None,
mode: str,
requested_fragment_type: str | None,
diagnostics: dict[str, Any],
) -> str:
if mode == "OPENAPI_FRAGMENT_GENERATE":
if requested_fragment_type == "response_schema":
return self._render_schema(response_schema, diagnostics, "Documented response fragment")
return self._render_schema(request_schema or response_schema, diagnostics, "Documented schema fragment")
if not path:
return ""
method_line = method or "get"
summary = str(diagnostics.get("operation_summary") or "Documented API method")
response_block = self._render_responses(response_schema, diagnostics)
request_block = self._render_request_body(request_schema, diagnostics)
return "\n".join(
[
"paths:",
f" {path}:",
f" {method_line}:",
f" summary: \"{summary}\"",
*request_block,
" responses:",
*response_block,
]
)
def _render_schema(self, schema: dict[str, Any] | None, diagnostics: dict[str, Any], fallback_description: str) -> str:
if schema is None:
return "\n".join(["type: object", f"description: \"{fallback_description}\""])
return self._yaml_from_object(schema, indent=0)
def _render_request_body(self, schema: dict[str, Any] | None, diagnostics: dict[str, Any]) -> list[str]:
lines = [" requestBody:"]
if schema is None:
description = "Documented request payload" if diagnostics.get("request_payload_found") else "Request payload not fully documented"
lines.append(f" description: \"{description}\"")
return lines
lines.append(" content:")
content_type = str(diagnostics.get("content_type") or "application/json")
lines.append(f" {content_type}:")
lines.append(" schema:")
lines.extend(self._yaml_from_object(schema, indent=14).splitlines())
return lines
def _render_responses(self, schema: dict[str, Any] | None, diagnostics: dict[str, Any]) -> list[str]:
status_codes = list(diagnostics.get("status_codes") or []) or ["200"]
code = status_codes[0]
lines = [f" \"{code}\":", " description: \"Documented response\""]
if schema is not None:
lines.append(" content:")
content_type = str(diagnostics.get("content_type") or "application/json")
lines.append(f" {content_type}:")
lines.append(" schema:")
lines.extend(self._yaml_from_object(schema, indent=16).splitlines())
return lines
def _yaml_from_object(self, value: dict[str, Any], *, indent: int) -> str:
lines: list[str] = []
prefix = " " * indent
for key, item in value.items():
if isinstance(item, dict):
lines.append(f"{prefix}{key}:")
lines.append(self._yaml_from_object(item, indent=indent + 2))
elif isinstance(item, list):
lines.append(f"{prefix}{key}:")
for row in item:
lines.append(f"{prefix} - {row}")
else:
lines.append(f"{prefix}{key}: {item}")
return "\n".join(lines)
@@ -0,0 +1,37 @@
from __future__ import annotations
import yaml
class OpenAPIPostprocessor:
_ALLOWED_METHODS = {"get", "post", "put", "delete", "patch"}
def validate(self, answer: str, *, require_paths: bool) -> tuple[bool, dict]:
try:
payload = yaml.safe_load(answer) or {}
except yaml.YAMLError:
return False, {"reason": "invalid_yaml"}
if not isinstance(payload, dict):
return False, {"reason": "invalid_yaml_root"}
if require_paths:
paths = payload.get("paths")
if not isinstance(paths, dict) or not paths:
return False, {"reason": "missing_paths"}
methods = self._methods(paths)
if not methods:
return False, {"reason": "missing_method"}
return True, {"reason": "ok", "methods": methods}
if not isinstance(payload, dict) or not payload:
return False, {"reason": "empty_schema"}
return True, {"reason": "ok"}
def _methods(self, paths: dict) -> list[str]:
methods: list[str] = []
for item in paths.values():
if not isinstance(item, dict):
continue
for method in item.keys():
method_name = str(method).lower()
if method_name in self._ALLOWED_METHODS and method_name not in methods:
methods.append(method_name)
return methods
@@ -0,0 +1,664 @@
from __future__ import annotations
import math
from time import perf_counter
from typing import Any
from app.modules.agent.llm import AgentLlmService
from app.modules.agent.llm.prompt_loader import PromptLoader
from app.modules.agent.intent_router_v2.analysis.docs_query_signals import DocsQuerySignals
from app.modules.agent.runtime.docs_qa_pipeline.answer_synthesizer import DocsAnswerSynthesizer
from app.modules.agent.runtime.docs_qa_pipeline.anchor_selector import DocsAnchorSelector
from app.modules.agent.runtime.docs_qa_pipeline.diagnostics_builder import DocsDiagnosticsBuilder
from app.modules.agent.runtime.docs_qa_pipeline.evidence_builder import DocsEvidenceBuilder
from app.modules.agent.runtime.docs_qa_pipeline.exact_anchor_matcher import DocsExactAnchorMatcher
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsQAPipelineResult
from app.modules.agent.runtime.docs_qa_pipeline.openapi_postprocessor import OpenAPIPostprocessor
from app.modules.agent.runtime.docs_qa_pipeline.openapi_generator import OpenAPIGenerator
from app.modules.agent.runtime.docs_qa_pipeline.prompt_payload_builder import DocsPromptPayloadBuilder
from app.modules.agent.runtime.legacy_pipeline import RetrievalAdapter
from app.modules.agent.runtime.steps.context import build_retrieval_request
from app.modules.agent.runtime.steps.generation import RuntimePromptSelector
class DocsQAPipelineRunner:
def __init__(
self,
router: Any,
retrieval_adapter: RetrievalAdapter,
repo_context: Any = None,
llm: AgentLlmService | None = None,
evidence_builder: DocsEvidenceBuilder | None = None,
answer_synthesizer: DocsAnswerSynthesizer | None = None,
openapi_generator: OpenAPIGenerator | None = None,
diagnostics_builder: DocsDiagnosticsBuilder | None = None,
prompt_selector: RuntimePromptSelector | None = None,
prompt_payload_builder: DocsPromptPayloadBuilder | None = None,
openapi_postprocessor: OpenAPIPostprocessor | None = None,
exact_anchor_matcher: DocsExactAnchorMatcher | None = None,
anchor_selector: DocsAnchorSelector | None = None,
) -> None:
self._router = router
self._adapter = retrieval_adapter
self._repo_context = repo_context
self._llm = llm
self._evidence_builder = evidence_builder or DocsEvidenceBuilder()
self._answer_synthesizer = answer_synthesizer or DocsAnswerSynthesizer()
self._openapi_generator = openapi_generator or OpenAPIGenerator()
self._diagnostics_builder = diagnostics_builder or DocsDiagnosticsBuilder()
self._prompt_selector = prompt_selector or RuntimePromptSelector()
self._prompt_payload_builder = prompt_payload_builder or DocsPromptPayloadBuilder()
self._openapi_postprocessor = openapi_postprocessor or OpenAPIPostprocessor()
self._exact_anchor_matcher = exact_anchor_matcher or DocsExactAnchorMatcher()
self._anchor_selector = anchor_selector or DocsAnchorSelector()
self._docs_signals = DocsQuerySignals()
def run(
self,
user_query: str,
rag_session_id: str,
*,
conversation_state: Any = None,
mode: str = "full",
) -> DocsQAPipelineResult:
timings: dict[str, int] = {}
t0 = perf_counter()
router_result = self._router.route(
user_query,
conversation_state or _default_conversation_state(),
self._repo_context or _default_repo_context(),
)
timings["router"] = _ms(t0)
t1 = perf_counter()
request = build_retrieval_request(router_result, rag_session_id)
raw_rows = self._adapter.retrieve_with_plan(
rag_session_id,
request.query,
request.retrieval_spec,
request.retrieval_constraints,
query_plan=request.query_plan,
)
retrieval_report = self._adapter.consume_retrieval_report() if hasattr(self._adapter, "consume_retrieval_report") else {}
unfiltered_rows = list(raw_rows)
raw_rows, exact_anchor_match = self._exact_anchor_matcher.filter_rows(
raw_rows,
anchor_type=router_result.matched_anchor_type,
anchor_value=router_result.matched_anchor_value,
)
if request.sub_intent == "RELATED_DOCS_EXPLAIN" and not raw_rows and self._has_relation_hits(unfiltered_rows):
raw_rows = unfiltered_rows
timings["retrieval"] = _ms(t1)
t2 = perf_counter()
evidence_bundle = self._evidence_builder.build(
intent=router_result.intent,
sub_intent=request.sub_intent,
raw_rows=raw_rows,
)
anchor_selection = self._anchor_selector.select(
sub_intent=request.sub_intent,
anchor_type=router_result.matched_anchor_type,
anchor_value=router_result.matched_anchor_value,
rows=raw_rows,
)
openapi_result = None
prompt_name = self._prompt_selector.select(
intent=router_result.intent,
sub_intent=request.sub_intent,
answer_mode="normal",
)
llm_mode = self._llm_mode(router_result.intent, request.sub_intent)
output_valid = True
answer_mode = "diagnostic_only" if mode == "pre_llm_only" else "answered"
degraded_reason = ""
answer = ""
llm_request: dict[str, Any] = {}
requested_fragment_type = self._requested_fragment_type(user_query, request.sub_intent)
if router_result.intent in {"OPENAPI_GENERATION", "OPENAPI_FROM_DOCUMENTATION"}:
openapi_result = self._openapi_generator.generate(
evidence_bundle,
request.sub_intent,
requested_fragment_type=requested_fragment_type,
)
llm_request = self._build_llm_request(
question=user_query,
intent=router_result.intent,
sub_intent=request.sub_intent,
evidence_bundle=evidence_bundle,
prompt_name=prompt_name,
log_context="graph.project_qa.docs.openapi",
api_contract=openapi_result,
)
if mode == "pre_llm_only":
answer_mode, degraded_reason, output_valid = self._evaluate_openapi_gate(
user_query=user_query,
sub_intent=request.sub_intent,
router_result=router_result,
openapi_result=openapi_result,
exact_anchor_match=exact_anchor_match,
)
answer = openapi_result.raw_yaml if answer_mode != "degraded" else "Недостаточно contract evidence для OpenAPI."
else:
answer = self._generate_openapi_answer(user_query, router_result.intent, request.sub_intent, evidence_bundle, openapi_result)
output_valid, llm_details = self._openapi_postprocessor.validate(
answer,
require_paths=request.sub_intent != "OPENAPI_FRAGMENT_GENERATE",
)
if not output_valid:
answer = openapi_result.raw_yaml
openapi_result = openapi_result.model_copy(
update={"diagnostics": {**openapi_result.diagnostics, "llm_validation": llm_details}}
)
answer_mode, degraded_reason, output_valid, answer = self._finalize_openapi_answer(
answer=answer,
router_result=router_result,
openapi_result=openapi_result,
output_valid=output_valid,
exact_anchor_match=exact_anchor_match,
)
else:
llm_request = self._build_llm_request(
question=user_query,
intent=router_result.intent,
sub_intent=request.sub_intent,
evidence_bundle=evidence_bundle,
prompt_name=prompt_name,
log_context="graph.project_qa.docs.answer",
)
if mode == "pre_llm_only":
answer_mode, degraded_reason = self._evaluate_docs_gate(
raw_rows=raw_rows,
sub_intent=request.sub_intent,
matched_anchor_type=router_result.matched_anchor_type,
exact_anchor_match=exact_anchor_match,
matched_anchor_value=router_result.matched_anchor_value,
)
output_valid = answer_mode != "degraded"
else:
answer = self._generate_docs_answer(user_query, router_result.intent, request.sub_intent, evidence_bundle)
answer_mode, degraded_reason, answer = self._finalize_docs_answer(
answer=answer,
raw_rows=raw_rows,
sub_intent=request.sub_intent,
matched_anchor_type=router_result.matched_anchor_type,
exact_anchor_match=exact_anchor_match,
matched_anchor_value=router_result.matched_anchor_value,
)
gate_decision, gate_decision_reason, gate_missing_requirements, gate_satisfied_requirements = self._build_gate_details(
intent=router_result.intent,
sub_intent=request.sub_intent,
answer_mode=answer_mode,
degraded_reason=degraded_reason,
raw_rows=raw_rows,
exact_anchor_match=exact_anchor_match,
matched_anchor_type=router_result.matched_anchor_type,
matched_anchor_value=router_result.matched_anchor_value,
openapi_result=openapi_result,
router_result=router_result,
)
diagnostics = self._diagnostics_builder.build(
intent=router_result.intent,
sub_intent=request.sub_intent,
planned_layers=list(request.requested_layers),
executed_layers=list(retrieval_report.get("executed_layers") or request.requested_layers),
non_empty_layers=self._non_empty_layers(raw_rows, retrieval_report, request.sub_intent),
layer_diagnostics=dict(retrieval_report.get("layer_diagnostics") or {}),
evidence_bundle=evidence_bundle,
openapi_result=openapi_result,
prompt_used=prompt_name,
llm_mode=llm_mode,
answer_mode=answer_mode,
output_valid=output_valid,
matched_intent_source=router_result.matched_intent_source,
matched_anchor_type=router_result.matched_anchor_type,
matched_anchor_value=router_result.matched_anchor_value,
exact_anchor_match=exact_anchor_match,
query_entity_candidates=self._docs_signals.query_entity_candidates(user_query),
resolved_entity_candidates=self._exact_anchor_matcher.resolved_entity_candidates(raw_rows),
query_anchor_candidates=self._docs_signals.query_anchor_candidates(user_query),
resolved_anchor_candidates=self._exact_anchor_matcher.resolved_anchor_candidates(raw_rows),
anchor_candidates=list(anchor_selection.get("anchor_candidates") or []),
selected_anchor=anchor_selection.get("selected_anchor"),
anchor_selection_reason=str(anchor_selection.get("anchor_selection_reason") or ""),
anchor_match_type=str(anchor_selection.get("anchor_match_type") or ""),
docs_layers_with_hits=self._non_empty_layers(raw_rows, retrieval_report, request.sub_intent),
gate_decision=gate_decision,
gate_decision_reason=gate_decision_reason,
gate_missing_requirements=gate_missing_requirements,
gate_satisfied_requirements=gate_satisfied_requirements,
requested_fragment_type=("method" if request.sub_intent == "OPENAPI_METHOD_GENERATE" else requested_fragment_type),
fragment_evidence_found=self._fragment_evidence_found(requested_fragment_type, openapi_result),
fragment_missing_requirements=self._fragment_missing_requirements(requested_fragment_type, openapi_result),
prompt=llm_request,
degraded_reason=degraded_reason or None,
fallback_used=bool(dict(retrieval_report.get("fallback") or {}).get("used")),
code_intents_stubbed=False,
)
timings["execution"] = _ms(t2)
return DocsQAPipelineResult(
user_query=user_query,
rag_session_id=rag_session_id,
router_result=router_result,
retrieval_request=request,
evidence_bundle=evidence_bundle,
answer=answer,
diagnostics=diagnostics,
openapi_result=openapi_result,
prompt_name=prompt_name,
llm_request=llm_request,
output_valid=output_valid,
answer_mode=answer_mode,
degraded_reason=degraded_reason,
raw_rows=raw_rows,
timings_ms=timings,
mode=mode,
)
def _generate_docs_answer(self, question: str, intent: str, sub_intent: str, evidence_bundle) -> str:
if self._llm is None:
return self._answer_synthesizer.synthesize(question, evidence_bundle)
payload = self._prompt_payload_builder.build(
question=question,
intent=intent,
sub_intent=sub_intent,
evidence_bundle=evidence_bundle,
)
prompt_name = self._prompt_selector.select(intent=intent, sub_intent=sub_intent, answer_mode="normal")
return self._llm.generate(prompt_name, payload, log_context="graph.project_qa.docs.answer").strip()
def _generate_openapi_answer(self, question: str, intent: str, sub_intent: str, evidence_bundle, api_contract) -> str:
if self._llm is None:
return api_contract.raw_yaml
payload = self._prompt_payload_builder.build(
question=question,
intent=intent,
sub_intent=sub_intent,
evidence_bundle=evidence_bundle,
api_contract=api_contract,
)
prompt_name = self._prompt_selector.select(intent=intent, sub_intent=sub_intent, answer_mode="normal")
return self._llm.generate(prompt_name, payload, log_context="graph.project_qa.docs.openapi").strip()
def _llm_mode(self, intent: str, sub_intent: str) -> str:
if sub_intent == "RELATED_DOCS_EXPLAIN":
return "graph_summary"
if intent == "GENERAL_QA":
return "prose"
if intent in {"OPENAPI_GENERATION", "OPENAPI_FROM_DOCUMENTATION"}:
return "yaml"
return "prose"
def _build_llm_request(
self,
*,
question: str,
intent: str,
sub_intent: str,
evidence_bundle,
prompt_name: str,
log_context: str,
api_contract=None,
) -> dict[str, Any]:
user_prompt = self._prompt_payload_builder.build(
question=question,
intent=intent,
sub_intent=sub_intent,
evidence_bundle=evidence_bundle,
api_contract=api_contract,
)
system_prompt = PromptLoader().load(prompt_name) or "You are a helpful assistant."
tokens_in_estimate = max(1, int(math.ceil((len(system_prompt) + len(user_prompt)) / 4)))
return {
"prompt_name": prompt_name,
"system_prompt": system_prompt,
"user_prompt": user_prompt,
"log_context": log_context,
"prompt_stats": {
"system_chars": len(system_prompt),
"user_chars": len(user_prompt),
"tokens_in_estimate": tokens_in_estimate,
},
}
def _finalize_docs_answer(
self,
*,
answer: str,
raw_rows: list[dict],
sub_intent: str,
matched_anchor_type: str,
exact_anchor_match: bool,
matched_anchor_value: str | None,
) -> tuple[str, str, str]:
if self._should_reject_on_exact_anchor(
sub_intent=sub_intent,
raw_rows=raw_rows,
matched_anchor_type=matched_anchor_type,
matched_anchor_value=matched_anchor_value,
exact_anchor_match=exact_anchor_match,
):
return "degraded", "not_found_exact_anchor", "Не найдено точное совпадение по запрошенному docs anchor."
if not raw_rows:
return "degraded", "retrieval_empty", "По документации не найдено релевантных данных."
if not answer.strip():
return "degraded", "insufficient_docs_evidence", "Недостаточно подтвержденных данных в документации."
return "answered", "", answer
def _finalize_openapi_answer(
self,
*,
answer: str,
router_result,
openapi_result,
output_valid: bool,
exact_anchor_match: bool,
) -> tuple[str, str, bool, str]:
requested_endpoint = str(router_result.matched_anchor_value or "").strip()
generated_endpoint = str(openapi_result.path or "").strip()
diagnostics = dict(openapi_result.diagnostics or {})
if requested_endpoint and not exact_anchor_match:
return "degraded", "not_found_exact_anchor", False, "Не найден точный endpoint в документации."
if requested_endpoint and generated_endpoint and requested_endpoint != generated_endpoint:
return "degraded", "generated_endpoint_mismatch", False, "OpenAPI сгенерирован не по запрошенному endpoint."
if str(getattr(router_result.retrieval_spec.filters, "doc_type", "") or "") != "api_method":
return "degraded", "wrong_doc_type_match", False, "Для OpenAPI не найден подходящий api_method evidence."
if not generated_endpoint:
return "degraded", "insufficient_docs_evidence", False, "Недостаточно contract evidence для OpenAPI."
has_partial_contract = any(
(
openapi_result.request_schema is not None,
openapi_result.response_schema is not None,
diagnostics.get("status_codes_found"),
diagnostics.get("payload_description_found"),
)
)
if not has_partial_contract:
return "degraded", "insufficient_docs_evidence", False, "Недостаточно contract evidence для OpenAPI."
if not output_valid:
return "structured_spec_partial", "invalid_llm_output_fallback", False, answer
if openapi_result.method and openapi_result.response_schema is not None and openapi_result.request_schema is not None:
return "structured_spec", "", True, answer
if openapi_result.method or diagnostics.get("status_codes_found") or diagnostics.get("payload_description_found"):
return "structured_spec_partial", "answered_with_gaps", True, answer
return "degraded", "insufficient_docs_evidence", False, "Недостаточно contract evidence для OpenAPI."
def _evaluate_docs_gate(
self,
*,
raw_rows: list[dict],
sub_intent: str,
matched_anchor_type: str,
exact_anchor_match: bool,
matched_anchor_value: str | None,
) -> tuple[str, str]:
if self._should_reject_on_exact_anchor(
sub_intent=sub_intent,
raw_rows=raw_rows,
matched_anchor_type=matched_anchor_type,
matched_anchor_value=matched_anchor_value,
exact_anchor_match=exact_anchor_match,
):
return "degraded", "not_found_exact_anchor"
if not raw_rows:
return "degraded", "retrieval_empty"
return "ready", ""
def _evaluate_openapi_gate(
self,
*,
user_query: str,
sub_intent: str,
router_result,
openapi_result,
exact_anchor_match: bool,
) -> tuple[str, str, bool]:
requested_endpoint = str(router_result.matched_anchor_value or "").strip()
generated_endpoint = str(openapi_result.path or "").strip()
diagnostics = dict(openapi_result.diagnostics or {})
requested_fragment_type = self._requested_fragment_type(user_query, sub_intent)
has_operation_signal = bool(openapi_result.method) or bool(diagnostics.get("status_codes_found")) or bool(diagnostics.get("payload_description_found"))
has_contract_detail = any(
(
openapi_result.request_schema is not None,
openapi_result.response_schema is not None,
diagnostics.get("status_codes_found"),
diagnostics.get("request_fields_found"),
diagnostics.get("response_fields_found"),
diagnostics.get("payload_description_found"),
)
)
if requested_endpoint and not exact_anchor_match:
return "degraded", "not_found_exact_anchor", False
if requested_endpoint and generated_endpoint and requested_endpoint != generated_endpoint:
return "degraded", "generated_endpoint_mismatch", False
if str(getattr(router_result.retrieval_spec.filters, "doc_type", "") or "") != "api_method":
return "degraded", "wrong_doc_type_match", False
if not generated_endpoint or not has_operation_signal:
return "degraded", "insufficient_docs_evidence", False
if sub_intent == "OPENAPI_FRAGMENT_GENERATE":
return self._evaluate_fragment_gate(requested_fragment_type, openapi_result)
if not openapi_result.method and not diagnostics.get("status_codes_found"):
return "degraded", "insufficient_docs_evidence", False
if not has_contract_detail:
return "degraded", "insufficient_docs_evidence", False
if openapi_result.request_schema is None or openapi_result.response_schema is None:
return "ready_partial", "answered_with_gaps", True
return "ready", "", True
def _build_gate_details(
self,
*,
intent: str,
sub_intent: str,
answer_mode: str,
degraded_reason: str,
raw_rows: list[dict],
exact_anchor_match: bool,
matched_anchor_type: str,
matched_anchor_value: str | None,
openapi_result,
router_result,
) -> tuple[str, str, list[str], list[str]]:
satisfied: list[str] = []
missing: list[str] = []
relation_hits = self._relation_hits(raw_rows)
if raw_rows:
satisfied.append("retrieval_non_empty")
else:
missing.append("retrieval_non_empty")
if matched_anchor_type in {"endpoint", "entity"} and matched_anchor_value:
if exact_anchor_match or (sub_intent == "RELATED_DOCS_EXPLAIN" and relation_hits > 0):
satisfied.append("exact_anchor_match")
else:
missing.append("exact_anchor_match")
if intent in {"OPENAPI_GENERATION", "OPENAPI_FROM_DOCUMENTATION"} and openapi_result is not None:
if openapi_result.path:
satisfied.append("path_found")
else:
missing.append("path_found")
diagnostics = openapi_result.diagnostics
if openapi_result.method:
satisfied.append("http_method_found")
else:
missing.append("http_method_found")
if diagnostics.get("operation_semantics_found"):
satisfied.append("operation_semantics_found")
else:
missing.append("operation_semantics_found")
if diagnostics.get("request_payload_found"):
satisfied.append("request_payload_found")
if diagnostics.get("response_payload_found"):
satisfied.append("response_payload_found")
if openapi_result.request_schema is not None:
satisfied.append("request_fields_found")
if openapi_result.response_schema is not None:
satisfied.append("response_fields_found")
if openapi_result.request_schema is None and openapi_result.response_schema is None:
missing.append("contract_fields_found")
if diagnostics.get("status_codes_found"):
satisfied.append("status_codes_found")
else:
missing.append("status_codes_found")
if diagnostics.get("payload_description_found"):
satisfied.append("payload_description_found")
else:
missing.append("payload_description_found")
if diagnostics.get("content_type_found"):
satisfied.append("content_type_found")
if diagnostics.get("examples_found"):
satisfied.append("examples_found")
if str(getattr(router_result.retrieval_spec.filters, "doc_type", "") or "") == "api_method":
satisfied.append("api_method_filter")
else:
missing.append("api_method_filter")
gate = answer_mode
if gate in {"diagnostic_only", "ready", "answered", "structured_spec"}:
gate = "allow" if not degraded_reason else "reject"
elif gate in {"ready_partial", "structured_spec_partial"}:
gate = "partial"
elif gate == "degraded":
gate = "reject"
return gate, self._gate_reason(gate, degraded_reason, sub_intent, relation_hits), missing, satisfied
def _non_empty_layers(self, rows: list[dict], report: dict[str, Any], sub_intent: str) -> list[str]:
diagnostics = dict(report.get("layer_diagnostics") or {})
if sub_intent == "RELATED_DOCS_EXPLAIN" and int(dict(diagnostics.get("D5_RELATION_GRAPH") or {}).get("hits") or 0) > 0:
return ["D5_RELATION_GRAPH"]
if diagnostics:
return [layer for layer, payload in diagnostics.items() if int(dict(payload).get("hits") or 0) > 0]
return self._layers_with_hits(rows)
def _layers_with_hits(self, rows: list[dict]) -> list[str]:
result: list[str] = []
for row in rows:
layer = str(row.get("layer") or "")
if layer.startswith("D") and layer not in result:
result.append(layer)
return result
def _should_reject_on_exact_anchor(
self,
*,
sub_intent: str,
raw_rows: list[dict],
matched_anchor_type: str,
matched_anchor_value: str | None,
exact_anchor_match: bool,
) -> bool:
if matched_anchor_type not in {"endpoint", "entity"} or not matched_anchor_value or exact_anchor_match:
return False
if sub_intent == "RELATED_DOCS_EXPLAIN" and self._has_relation_hits(raw_rows):
return False
return True
def _has_relation_hits(self, rows: list[dict]) -> bool:
return self._relation_hits(rows) > 0
def _relation_hits(self, rows: list[dict]) -> int:
return sum(1 for row in rows if str(row.get("layer") or "") == "D5_RELATION_GRAPH")
def _gate_reason(self, gate: str, degraded_reason: str, sub_intent: str, relation_hits: int) -> str:
if degraded_reason:
return degraded_reason
if sub_intent == "RELATED_DOCS_EXPLAIN" and relation_hits > 0:
return "relation_evidence_available"
if gate == "partial":
return "partial_evidence_available"
if gate == "allow":
return "evidence_sufficient"
return ""
def _evaluate_fragment_gate(self, requested_fragment_type: str | None, openapi_result) -> tuple[str, str, bool]:
diagnostics = dict(openapi_result.diagnostics or {})
if requested_fragment_type == "request_schema":
if openapi_result.request_schema is not None:
return "ready", "", True
if diagnostics.get("request_fields_found") or diagnostics.get("request_payload_found") or diagnostics.get("payload_description_found"):
return "ready_partial", "fragment_payload_only", True
return "degraded", "insufficient_docs_evidence", False
if requested_fragment_type == "response_schema":
if openapi_result.response_schema is not None:
return "ready", "", True
if diagnostics.get("response_fields_found") or diagnostics.get("response_payload_found") or diagnostics.get("status_codes_found"):
return "ready_partial", "fragment_response_partial", True
return "degraded", "insufficient_docs_evidence", False
if requested_fragment_type == "parameters":
if diagnostics.get("has_method") or diagnostics.get("content_type_found"):
return "ready_partial", "fragment_parameters_partial", True
return "degraded", "insufficient_docs_evidence", False
if openapi_result.request_schema is not None or openapi_result.response_schema is not None:
return "ready_partial", "answered_with_gaps", True
if diagnostics.get("payload_description_found"):
return "ready_partial", "fragment_payload_only", True
return "degraded", "insufficient_docs_evidence", False
def _requested_fragment_type(self, user_query: str, sub_intent: str) -> str | None:
if sub_intent != "OPENAPI_FRAGMENT_GENERATE":
return None
lowered = user_query.lower()
if "request" in lowered:
return "request_schema"
if "response" in lowered:
return "response_schema"
if "parameter" in lowered or "парамет" in lowered:
return "parameters"
return "schema_fragment"
def _fragment_evidence_found(self, requested_fragment_type: str | None, openapi_result) -> list[str]:
if openapi_result is None or requested_fragment_type is None:
return []
diagnostics = dict(openapi_result.diagnostics or {})
found: list[str] = []
if openapi_result.path:
found.append("path")
if openapi_result.method:
found.append("method")
if diagnostics.get("operation_semantics_found"):
found.append("operation_semantics")
if diagnostics.get("payload_description_found"):
found.append("payload_description")
if diagnostics.get("status_codes_found"):
found.append("status_codes")
if requested_fragment_type == "request_schema" and openapi_result.request_schema is not None:
found.append("request_schema")
if requested_fragment_type == "request_schema" and diagnostics.get("request_fields_found"):
found.append("request_fields")
if requested_fragment_type == "response_schema" and openapi_result.response_schema is not None:
found.append("response_schema")
if requested_fragment_type == "response_schema" and diagnostics.get("response_fields_found"):
found.append("response_fields")
return found
def _fragment_missing_requirements(self, requested_fragment_type: str | None, openapi_result) -> list[str]:
if openapi_result is None or requested_fragment_type is None:
return []
diagnostics = dict(openapi_result.diagnostics or {})
missing: list[str] = []
if not openapi_result.path:
missing.append("path")
if requested_fragment_type == "request_schema":
if openapi_result.request_schema is None and not diagnostics.get("payload_description_found"):
missing.append("request_payload_evidence")
elif requested_fragment_type == "response_schema":
if openapi_result.response_schema is None and not diagnostics.get("status_codes_found"):
missing.append("response_payload_evidence")
elif openapi_result.request_schema is None and openapi_result.response_schema is None:
missing.append("schema_fragment")
return missing
def _default_conversation_state() -> Any:
from app.modules.agent.intent_router_v2 import ConversationState
return ConversationState()
def _default_repo_context() -> Any:
from app.modules.agent.intent_router_v2 import RepoContext
return RepoContext()
def _ms(started: float) -> int:
return int((perf_counter() - started) * 1000)
@@ -0,0 +1,37 @@
from __future__ import annotations
import json
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsEvidenceBundle, OpenAPIResult
class DocsPromptPayloadBuilder:
def build(
self,
*,
question: str,
intent: str,
sub_intent: str,
evidence_bundle: DocsEvidenceBundle,
api_contract: OpenAPIResult | None = None,
) -> str:
payload = {
"question": question,
"intent": intent,
"sub_intent": sub_intent,
"documents": list(evidence_bundle.documents),
"facts": list(evidence_bundle.facts),
"entities": list(evidence_bundle.entities),
"workflows": list(evidence_bundle.workflows),
"relations": list(evidence_bundle.relations),
"chunks": list(evidence_bundle.chunks),
}
if api_contract is not None:
payload["api_contract"] = {
"path": api_contract.path,
"method": api_contract.method,
"request_schema": api_contract.request_schema,
"response_schema": api_contract.response_schema,
"diagnostics": dict(api_contract.diagnostics),
}
return json.dumps(payload, ensure_ascii=False, indent=2)
+5 -1
View File
@@ -228,7 +228,11 @@ class AgentRuntimeExecutor:
post_gate=self._post_gate,
)
state.synthesis_input = build_answer_synthesis_input(user_query, state.evidence_pack)
prompt_name = self._prompt_selector.select(sub_intent=state.retrieval_request.sub_intent, answer_mode=state.answer_mode)
prompt_name = self._prompt_selector.select(
intent=state.router_result.intent,
sub_intent=state.retrieval_request.sub_intent,
answer_mode=state.answer_mode,
)
prompt_payload = self._payload_builder.build(
user_query=user_query,
synthesis_input=state.synthesis_input,
@@ -43,7 +43,10 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
target = str(bundle.resolved_target or "").strip()
signatures = [_signature_payload(chunk) for chunk in chunks if chunk.layer == "C1_SYMBOL_CATALOG"]
target_signatures = [item for item in signatures if _is_target_symbol(item["name"], target)]
methods = _unique(item["name"] for item in target_signatures if item["kind"] == "method")
# Конкретные имена методов: полный qname и короткое отображаемое имя (Class.method или method)
methods_full = _unique(item["name"] for item in target_signatures if item["kind"] == "method")
methods_short = _unique(_short_method_name(name) for name in methods_full)
methods = methods_short or methods_full[:6]
constructor_args = _unique(
arg
for item in target_signatures
@@ -51,6 +54,7 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
for arg in item["args"]
if arg not in {"self", "cls"}
)
# Конкретные вызовы: из relations и fallback из кода
calls = _unique(
_display_call_target(relation["target"])
for relation in relations
@@ -58,6 +62,7 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
)
if not calls:
calls = _fallback_calls(chunks, target)
# Поля: из relations и из self.attr в коде
fields = _unique(
relation["target"].split(".", 1)[-1]
for relation in relations
@@ -65,6 +70,7 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
)
if not fields:
fields = _unique(field for chunk in chunks if _chunk_matches_target(chunk, target) for field in _FIELD_RE.findall(chunk.content or ""))
# Зависимости: импорты и инстанциации
dependencies = _unique(
_display_dependency_target(relation["target"])
for relation in relations
@@ -80,14 +86,16 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
fact_gaps.append("Конкретные вызовы целевой сущности не подтверждены в C2/C0.")
if not dependencies:
fact_gaps.append("Явные зависимости целевой сущности не подтверждены.")
if methods and not (calls or dependencies):
fact_gaps.append("Есть методы, но вызовы/зависимости не извлечены — опирайся только на перечисленные методы и поля.")
return {
"required_symbols": required_symbols[:8],
"required_methods": methods[:6],
"required_calls": calls[:6],
"required_fields": fields[:6],
"required_constructor_args": constructor_args[:6],
"required_dependencies": dependencies[:6],
"required_methods": methods[:8],
"required_calls": calls[:8],
"required_fields": fields[:8],
"required_constructor_args": constructor_args[:8],
"required_dependencies": dependencies[:8],
"required_files": required_files[:4],
"fact_gaps": fact_gaps,
}
@@ -95,12 +103,17 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
def _architecture_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relations: list[dict[str, Any]]) -> dict[str, Any]:
target = str(bundle.resolved_target or "").strip()
components = _unique(
([target] if target else [])
+ [_component_name(relation["source"]) for relation in relations]
# Компоненты: target, из связей, из заголовков чанков (классы/модули)
from_relations = _unique(
[_component_name(relation["source"]) for relation in relations]
+ [_component_name(relation["target"]) for relation in relations]
+ [_component_name(chunk.title) for chunk in chunks if _chunk_matches_target(chunk, target)]
)
from_chunks = _unique(
_component_name(chunk.title) or _component_name(str(dict(chunk.metadata or {}).get("qname") or ""))
for chunk in chunks
if chunk.layer in ("C1_SYMBOL_CATALOG", "C0_SOURCE_CHUNKS") and (target and _chunk_matches_target(chunk, target))
)
components = _unique(([target] if target else []) + from_relations + from_chunks)
relation_rows = [
{
"source": _component_name(relation["source"]),
@@ -117,14 +130,19 @@ def _architecture_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], rel
]
relation_rows = [row for row in relation_rows if row["source"] != row["target"]]
relation_verbs = _unique(row["verb"] for row in relation_rows if row["verb"])
# Краткие формулировки связей для обязательного упоминания в ответе
relation_summaries = [f"{r['source']} {r['verb']} {r['target']}" for r in relation_rows[:8]]
fact_gaps: list[str] = []
if not relation_rows:
fact_gaps.append("Concrete code edges между компонентами не подтверждены.")
if components and not relation_rows:
fact_gaps.append("Компоненты есть, но связи между ними не извлечены — не придумывай связи.")
return {
"required_components": components[:8],
"required_relations": relation_rows[:8],
"required_relation_verbs": relation_verbs[:6],
"required_components": components[:10],
"required_relations": relation_rows[:10],
"required_relation_summaries": relation_summaries[:8],
"required_relation_verbs": relation_verbs[:8],
"required_creation_edges": [row for row in relation_rows if row["edge_type"] == "instantiates"][:4],
"required_call_edges": [row for row in relation_rows if row["edge_type"] == "calls"][:4],
"required_registration_edges": [row for row in relation_rows if row["edge_type"] == "imports"][:4],
@@ -136,6 +154,10 @@ def _architecture_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], rel
def _trace_flow_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relations: list[dict[str, Any]]) -> dict[str, Any]:
target = str(bundle.resolved_target or "").strip()
sorted_relations = sorted(
relations,
key=lambda item: (item["path"], item["sort_line"], item["source"], item["target"]),
)
flow_steps = [
{
"step": index,
@@ -145,18 +167,27 @@ def _trace_flow_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relat
"path": relation["path"],
"line_span": relation["line_span"],
}
for index, relation in enumerate(sorted(relations, key=lambda item: (item["path"], item["sort_line"], item["source"], item["target"])), start=1)
for index, relation in enumerate(sorted_relations, start=1)
]
# Упорядоченные короткие формулировки шагов для обязательного отражения в ответе
ordered_step_descriptions = [
f"{i}. {_component_name(s['source'])} {s['verb']} {_display_call_target(s['target'])}"
for i, s in enumerate(flow_steps[:8], start=1)
]
required_calls = _unique(_display_call_target(item["target"]) for item in flow_steps)
fact_gaps: list[str] = []
if len(flow_steps) < 2:
fact_gaps.append("Полная последовательность шагов не подтверждена; виден только частичный flow.")
if not flow_steps:
fact_gaps.append("Конкретные sequence edges для flow не подтверждены.")
if flow_steps and len(flow_steps) < 3:
fact_gaps.append("Цепочка короткая — не заявляй полноту потока.")
return {
"required_flow_steps": flow_steps[:8],
"required_calls": _unique(_display_call_target(item["target"]) for item in flow_steps),
"required_sequence_edges": flow_steps[:8],
"required_flow_steps": flow_steps[:10],
"required_calls": required_calls[:10],
"required_sequence_edges": flow_steps[:10],
"ordered_step_descriptions": ordered_step_descriptions[:8],
"required_files": _unique(chunk.path for chunk in chunks if _chunk_matches_target(chunk, target) and chunk.path)[:4],
"fact_gaps": fact_gaps,
}
@@ -257,6 +288,18 @@ def _component_name(value: str) -> str:
return ".".join(parts[:-1])
def _short_method_name(qname: str) -> str:
"""Короткое отображаемое имя метода для ответа (Class.method или method())."""
clean = _clean_endpoint(qname)
if not clean:
return ""
parts = clean.split(".")
tail = parts[-1]
if len(parts) >= 2 and parts[-2][:1].isupper():
return f"{parts[-2]}.{tail}()" if tail != "__init__" else f"{parts[-2]}.__init__()"
return f"{tail}()" if tail else clean
def _display_call_target(value: str) -> str:
clean = _clean_endpoint(value)
if not clean:
@@ -41,18 +41,28 @@ class RuntimeAnswerRepairService:
"missing_concrete_methods": "missing_concrete_methods",
"missing_concrete_calls": "missing_concrete_calls",
"missing_concrete_dependencies": "missing_concrete_dependencies",
"missing_concrete_fields": "missing_concrete_fields",
"ignores_concrete_explain_facts": "too_vague_for_explain",
"too_vague_for_explain": "too_vague_for_explain",
"missing_concrete_components": "missing_concrete_components",
"missing_concrete_relations": "missing_concrete_relations",
"missing_relation_verbs": "missing_relation_verbs",
"target_mentioned_but_no_relations": "missing_concrete_relations",
"too_vague_for_architecture": "too_vague_for_architecture",
"missing_flow_steps": "missing_flow_steps",
"missing_sequence_edges": "missing_sequence_edges",
"too_vague_for_explain": "too_vague_for_explain",
"too_vague_for_architecture": "too_vague_for_architecture",
"missing_ordered_flow_steps": "missing_flow_steps",
"too_vague_for_trace_flow": "too_vague_for_trace_flow",
"semantic_labels_without_code_edges": "semantic_labels_without_code_edges",
"contains_retrieval_artifacts": "contains_retrieval_artifacts",
"methods_as_primary_components": "methods_as_primary_components",
"overclaims_trace_completeness": "overclaims_trace_completeness",
}
result = [mapping[reason] for reason in reasons if reason in mapping]
seen: set[str] = set()
result: list[str] = []
for reason in reasons:
focus = mapping.get(reason)
if focus and focus not in seen:
seen.add(focus)
result.append(focus)
return result or ["tighten_to_evidence"]
@@ -30,8 +30,20 @@ _VAGUE_PHRASES = (
"этап пайплайна",
"инициализация сервисов",
"регистрация основных служб",
"различные аргументы",
"различные подпакеты",
"основные службы",
"представляет собой",
"используется в службах",
)
_OPTIMISTIC_TRACE_CLAIMS = (
"полностью восстанавливается",
"полный поток выполнения",
"полностью прослеживается",
"полный поток виден",
"полная цепочка",
"весь поток",
)
_OPTIMISTIC_TRACE_CLAIMS = ("полностью восстанавливается", "полный поток выполнения", "полностью прослеживается")
class RuntimePostEvidenceGate:
@@ -96,11 +108,12 @@ class RuntimePostEvidenceGate:
reasons = self._validate_target_focus(answer, evidence_pack)
reasons.extend(self._vagueness_reasons(answer, "explain"))
matches = 0
methods = list(explain.get("required_methods") or [])
calls = list(explain.get("required_calls") or [])
dependencies = list(explain.get("required_dependencies") or [])
fields = list(explain.get("required_fields") or [])
has_any_required = bool(methods or calls or dependencies or fields)
matches = 0
if methods and not self._mentions_fact_group(answer, methods):
reasons.append("missing_concrete_methods")
elif methods:
@@ -113,10 +126,14 @@ class RuntimePostEvidenceGate:
reasons.append("missing_concrete_dependencies")
elif dependencies:
matches += 1
if fields and self._mentions_fact_group(answer, fields):
if fields and not self._mentions_fact_group(answer, fields):
reasons.append("missing_concrete_fields")
elif fields:
matches += 1
if (methods or calls or dependencies or fields) and matches == 0:
if has_any_required and matches == 0:
reasons.append("too_vague_for_explain")
if has_any_required and self._answer_is_generic_with_facts(answer, methods + calls + dependencies + fields):
reasons.append("ignores_concrete_explain_facts")
if self._semantic_leakage(answer, facts, has_concrete_support=matches > 0):
reasons.append("semantic_labels_without_code_edges")
return reasons
@@ -136,13 +153,19 @@ class RuntimePostEvidenceGate:
reasons.append("missing_concrete_relations")
if verbs and not self._mentions_fact_group(answer, verbs):
reasons.append("missing_relation_verbs")
if any(label in answer for label in architecture.get("forbidden_labels") or []):
forbidden = architecture.get("forbidden_labels") or []
if any(label.lower() in answer.lower() for label in forbidden):
reasons.append("contains_retrieval_artifacts")
if self._methods_dominate_components(answer, components):
reasons.append("methods_as_primary_components")
if relations and (not self._mentions_relations(answer, relations) or not self._mentions_fact_group(answer, verbs)):
has_relations = self._mentions_relations(answer, relations)
has_verbs = self._mentions_fact_group(answer, verbs)
if relations and (not has_relations or not has_verbs):
reasons.append("too_vague_for_architecture")
if self._semantic_leakage(answer, facts, has_concrete_support=self._mentions_relations(answer, relations)):
target = str(evidence_pack.resolved_target or "").strip().lower()
if target and target in answer and relations and not has_relations:
reasons.append("target_mentioned_but_no_relations")
if self._semantic_leakage(answer, facts, has_concrete_support=has_relations):
reasons.append("semantic_labels_without_code_edges")
return reasons
@@ -154,16 +177,19 @@ class RuntimePostEvidenceGate:
steps = list(trace.get("required_flow_steps") or [])
calls = list(trace.get("required_calls") or [])
ordered_descriptions = list(trace.get("ordered_step_descriptions") or [])
if steps and not self._mentions_steps(answer, steps):
reasons.append("missing_flow_steps")
if calls and not self._mentions_fact_group(answer, calls):
reasons.append("missing_concrete_calls")
if steps and not self._mentions_relations(answer, steps):
reasons.append("missing_sequence_edges")
if any(claim in answer for claim in _OPTIMISTIC_TRACE_CLAIMS):
if calls and not self._mentions_fact_group(answer, calls):
reasons.append("missing_concrete_calls")
if any(claim.lower() in answer.lower() for claim in _OPTIMISTIC_TRACE_CLAIMS):
reasons.append("overclaims_trace_completeness")
if steps and not (self._mentions_steps(answer, steps) and self._mentions_relations(answer, steps)):
reasons.append("too_vague_for_trace_flow")
if ordered_descriptions and not self._mentions_ordered_steps(answer, ordered_descriptions):
reasons.append("missing_ordered_flow_steps")
return reasons
def _validate_target_focus(self, answer: str, evidence_pack: EvidenceBundle) -> list[str]:
@@ -193,6 +219,27 @@ class RuntimePostEvidenceGate:
mentioned = sum(1 for step in steps[:3] if self._mentions_relations(answer, [step]))
return mentioned >= min(2, len(steps[:3]))
def _mentions_ordered_steps(self, answer: str, ordered_descriptions: list[str]) -> bool:
"""Проверяет, что в ответе отражена хотя бы часть упорядоченных шагов (источник/цель/глагол)."""
if not ordered_descriptions:
return True
answer_lower = answer.lower()
hits = 0
for desc in ordered_descriptions[:5]:
parts = desc.replace(".", " ").split()
if len(parts) >= 3 and any(p in answer_lower for p in parts if len(p) > 2):
hits += 1
return hits >= min(2, len(ordered_descriptions))
def _answer_is_generic_with_facts(
self, answer: str, concrete_values: list[str]
) -> bool:
"""True, если в ответе есть общие фразы, но почти нет конкретных имён из списка."""
if not concrete_values:
return False
mentioned = sum(1 for v in concrete_values if any(a in answer.lower() for a in _aliases(v)))
return mentioned == 0 and len(answer) > 100
def _methods_dominate_components(self, answer: str, components: list[str]) -> bool:
method_like = re.findall(r"\b[a-z_]+\(\)", answer)
component_hits = sum(1 for component in components if component.lower() in answer)
@@ -74,30 +74,55 @@ class RuntimePromptPayloadBuilder:
curated = dict(synthesis_input.curated_facts or {})
if scenario == "EXPLAIN":
facts = dict(curated.get("explain") or {})
must_methods = facts.get("required_methods", [])
must_calls = facts.get("required_calls", [])
must_deps = facts.get("required_dependencies", [])
must_fields = facts.get("required_fields", [])
fact_gaps = facts.get("fact_gaps", [])
return {
"must_mention_methods": facts.get("required_methods", []),
"must_mention_fields": facts.get("required_fields", []),
"must_mention_calls": facts.get("required_calls", []),
"must_mention_dependencies": facts.get("required_dependencies", []),
"answer_contract": (
"Ответ обязан опираться на конкретные факты ниже. Если списки непусты — назови явно методы, вызовы, зависимости или поля из них. "
"Не заменяй их общими фразами. Учитывай fact_gaps."
),
"must_mention_methods": must_methods,
"must_mention_fields": must_fields,
"must_mention_calls": must_calls,
"must_mention_dependencies": must_deps,
"must_mention_constructor_args": facts.get("required_constructor_args", []),
"must_mention_files": facts.get("required_files", []),
"must_not_infer_missing_details": True,
"fact_gaps": facts.get("fact_gaps", []),
"fact_gaps": fact_gaps,
}
if scenario == "ARCHITECTURE":
facts = dict(curated.get("architecture") or {})
components = facts.get("required_components", [])
relations = facts.get("required_relations", [])
verbs = facts.get("required_relation_verbs", [])
summaries = facts.get("required_relation_summaries", [])
return {
"must_mention_components": facts.get("required_components", []),
"must_mention_relations": facts.get("required_relations", []),
"must_use_relation_verbs": facts.get("required_relation_verbs", []),
"answer_contract": (
"Ответ обязан перечислить компоненты и связи из кода. Используй relation verbs (создаёт, вызывает, импортирует и т.д.). "
"Не подменяй архитектуру списком методов. Не используй retrieval labels в тексте."
),
"must_mention_components": components,
"must_mention_relations": relations,
"must_mention_relation_summaries": summaries,
"must_use_relation_verbs": verbs,
"must_avoid_semantic_labels_as_primary_claims": True,
"must_not_use_retrieval_labels": facts.get("forbidden_labels", []),
"fact_gaps": facts.get("fact_gaps", []),
}
if scenario == "TRACE_FLOW":
facts = dict(curated.get("trace_flow") or {})
steps = facts.get("required_flow_steps", [])
ordered_descriptions = facts.get("ordered_step_descriptions", [])
return {
"must_mention_flow_steps": facts.get("required_flow_steps", []),
"answer_contract": (
"Ответ обязан описать поток как упорядоченную последовательность шагов из payload. "
"Не заявляй полноту потока, если в fact_gaps указано иное. Не делай неподтверждённых утверждений."
),
"must_mention_flow_steps": steps,
"must_mention_ordered_steps": ordered_descriptions,
"must_mention_calls": facts.get("required_calls", []),
"must_mention_sequence_edges": facts.get("required_sequence_edges", []),
"must_avoid_overclaiming_full_flow": True,
@@ -4,7 +4,7 @@ from __future__ import annotations
class RuntimePromptSelector:
_PROMPTS = {
_CODE_PROMPTS = {
"ARCHITECTURE": "code_qa_architecture_answer",
"EXPLAIN": "code_qa_explain_answer",
"EXPLAIN_LOCAL": "code_qa_explain_local_answer",
@@ -14,8 +14,19 @@ class RuntimePromptSelector:
"OPEN_FILE": "code_qa_open_file_answer",
"TRACE_FLOW": "code_qa_trace_flow_answer",
}
_DOCS_INTENT_PROMPTS = {
"DOCUMENTATION_EXPLAIN": "docs_explain_answer",
"GENERAL_QA": "docs_general_answer",
}
def select(self, *, sub_intent: str, answer_mode: str) -> str:
def select(self, *, intent: str = "CODE_QA", sub_intent: str, answer_mode: str) -> str:
intent_key = (intent or "CODE_QA").upper()
if intent_key in {"OPENAPI_GENERATION", "OPENAPI_FROM_DOCUMENTATION"}:
if sub_intent.upper() == "OPENAPI_FRAGMENT_GENERATE":
return "docs_openapi_fragment_answer"
return "docs_openapi_answer"
if intent_key in self._DOCS_INTENT_PROMPTS:
return self._DOCS_INTENT_PROMPTS[intent_key]
if answer_mode in {"degraded", "not_found", "insufficient"}:
return "code_qa_degraded_answer"
return self._PROMPTS.get(sub_intent.upper(), "code_qa_explain_answer")
return self._CODE_PROMPTS.get(sub_intent.upper(), "code_qa_explain_answer")
@@ -38,6 +38,8 @@ class SessionEmbeddingDimensions:
class RuntimeRetrievalAdapter:
_RELATED_DOCS_THRESHOLD = 2
def __init__(self, repository: RagRepository | None = None) -> None:
if repository is None:
from app.modules.rag.persistence.repository import RagRepository
@@ -57,10 +59,23 @@ class RuntimeRetrievalAdapter:
query_plan=None,
) -> list[dict]:
rows: list[dict] = []
planned_layers = [str(item.layer_id) for item in list(getattr(retrieval_spec, "layer_queries", []) or [])]
executed_layers: list[str] = []
per_layer_ms: dict[str, int] = {}
layer_diagnostics: dict[str, Any] = {}
fallback_used = False
fallback_reason: str | None = None
relation_hits = 0
query_plan_sub_intent = str(getattr(query_plan, "sub_intent", "") or "")
for layer_query in list(getattr(retrieval_spec, "layer_queries", []) or []):
layer_id = str(layer_query.layer_id)
if (
query_plan_sub_intent == "RELATED_DOCS_EXPLAIN"
and layer_id in {"D1_DOCUMENT_CATALOG", "D0_DOC_CHUNKS"}
and relation_hits >= self._RELATED_DOCS_THRESHOLD
):
layer_diagnostics[layer_id] = {"hits": 0, "top_ids": [], "skipped": True, "reason": "relation_primary_sufficient"}
continue
executed_layers.append(layer_id)
started = perf_counter()
layer_rows = self._retrieve_layer(
@@ -73,8 +88,35 @@ class RuntimeRetrievalAdapter:
include_tests=str(getattr(retrieval_spec.filters, "test_policy", "EXCLUDE") or "EXCLUDE") == "INCLUDE",
)
per_layer_ms[layer_id] = int((perf_counter() - started) * 1000)
layer_diagnostics[layer_id] = self._layer_diagnostics(layer_rows)
rows.extend(layer_rows)
if layer_id == "D5_RELATION_GRAPH":
relation_hits = len(layer_rows)
d2_empty = "D2_FACT_INDEX" in planned_layers and int(dict(layer_diagnostics.get("D2_FACT_INDEX") or {}).get("hits") or 0) == 0
d0_empty = "D0_DOC_CHUNKS" in planned_layers and int(dict(layer_diagnostics.get("D0_DOC_CHUNKS") or {}).get("hits") or 0) == 0
support_paths = self._support_paths(rows)
if support_paths and "D0_DOC_CHUNKS" in planned_layers and (d2_empty or d0_empty):
targeted_started = perf_counter()
targeted_rows = self.retrieve_exact_files(
rag_session_id,
paths=support_paths,
layers=["D0_DOC_CHUNKS"],
limit=max(6, self._planned_top_k(retrieval_spec, "D0_DOC_CHUNKS")),
query=query,
ranking_profile=str(getattr(retrieval_spec, "rerank_profile", "") or ""),
)
merged_rows = self._dedupe([*rows, *targeted_rows])
new_targeted_rows = self._subtract_rows(merged_rows, rows)
per_layer_ms["D0_DOC_CHUNKS"] = per_layer_ms.get("D0_DOC_CHUNKS", 0) + int((perf_counter() - targeted_started) * 1000)
rows = merged_rows
layer_diagnostics["D0_DOC_CHUNKS"] = self._layer_diagnostics(
[row for row in rows if str(row.get("layer") or "") == "D0_DOC_CHUNKS"]
)
if new_targeted_rows:
fallback_used = True
fallback_reason = "targeted_chunk_retrieval"
self._last_report = {
"planned_layers": planned_layers,
"executed_layers": executed_layers,
"retrieval_mode_by_layer": {layer_id: "vector" for layer_id in executed_layers},
"top_k_by_layer": {str(item.layer_id): int(item.top_k) for item in list(getattr(retrieval_spec, "layer_queries", []) or [])},
@@ -82,8 +124,9 @@ class RuntimeRetrievalAdapter:
layer_id: {"path_scope": list(getattr(retrieval_spec.filters, "path_scope", []) or [])}
for layer_id in executed_layers
},
"fallback": {"used": False, "reason": None},
"fallback": {"used": fallback_used, "reason": fallback_reason},
"retrieval_by_layer_ms": per_layer_ms,
"layer_diagnostics": layer_diagnostics,
}
return self._dedupe(rows)
@@ -241,3 +284,54 @@ class RuntimeRetrievalAdapter:
seen.add(key)
result.append(row)
return result
def _layer_diagnostics(self, rows: list[dict]) -> dict[str, Any]:
ids: list[str] = []
sections: list[str] = []
for row in rows[:5]:
metadata = dict(row.get("metadata") or {})
candidate = (
metadata.get("document_id")
or metadata.get("doc_id")
or metadata.get("fact_id")
or metadata.get("relation_id")
or metadata.get("target_id")
or row.get("path")
)
value = str(candidate or "").strip()
if value and value not in ids:
ids.append(value)
title = str(row.get("title") or "").strip()
if title and title not in sections:
sections.append(title)
return {"hits": len(rows), "top_ids": ids, "top_sections": sections}
def _subtract_rows(self, rows: list[dict], baseline: list[dict]) -> list[dict]:
baseline_keys = {self._row_key(row) for row in baseline}
return [row for row in rows if self._row_key(row) not in baseline_keys]
def _support_paths(self, rows: list[dict]) -> list[str]:
values: list[str] = []
for row in rows:
layer = str(row.get("layer") or "")
if layer not in {"D1_DOCUMENT_CATALOG", "D2_FACT_INDEX", "D3_ENTITY_CATALOG", "D4_WORKFLOW_INDEX", "D5_RELATION_GRAPH"}:
continue
path = str(row.get("path") or "").strip()
if path and path not in values:
values.append(path)
return values[:6]
def _planned_top_k(self, retrieval_spec, layer_id: str) -> int:
for item in list(getattr(retrieval_spec, "layer_queries", []) or []):
if str(item.layer_id) == layer_id:
return int(item.top_k)
return 6
def _row_key(self, row: dict) -> tuple[str, str, str, int | None, int | None]:
return (
str(row.get("layer") or ""),
str(row.get("path") or ""),
str(row.get("title") or ""),
row.get("span_start"),
row.get("span_end"),
)
@@ -13,10 +13,12 @@ class RuntimeRepoContextFactory:
RagLayer.CODE_DEPENDENCY_GRAPH,
RagLayer.CODE_SEMANTIC_ROLES,
RagLayer.CODE_SOURCE_CHUNKS,
RagLayer.DOCS_MODULE_CATALOG,
RagLayer.DOCS_DOC_CHUNKS,
RagLayer.DOCS_DOCUMENT_CATALOG,
RagLayer.DOCS_FACT_INDEX,
RagLayer.DOCS_SECTION_INDEX,
RagLayer.DOCS_POLICY_INDEX,
RagLayer.DOCS_ENTITY_CATALOG,
RagLayer.DOCS_WORKFLOW_INDEX,
RagLayer.DOCS_RELATION_GRAPH,
]
def build(self, files_map: dict[str, dict] | None = None) -> RepoContext:
@@ -0,0 +1,13 @@
from app.modules.agent.task_runtime.context import TaskRuntimeContextBuilder
from app.modules.agent.task_runtime.dispatcher import TaskWorkflowDispatcher
from app.modules.agent.task_runtime.enrichment import ContextEnrichmentService
from app.modules.agent.task_runtime.facade import AgentTaskRuntimeFacade
from app.modules.agent.task_runtime.templates import DocumentationTemplateRegistry
__all__ = [
"AgentTaskRuntimeFacade",
"ContextEnrichmentService",
"DocumentationTemplateRegistry",
"TaskRuntimeContextBuilder",
"TaskWorkflowDispatcher",
]
@@ -0,0 +1,45 @@
from __future__ import annotations
from app.modules.agent.intent_router_v2 import ConversationState
from app.modules.agent.runtime.steps.retrieval import RuntimeRepoContextFactory
from app.modules.agent.task_runtime.models import TaskRuntimeContext
class TaskRuntimeContextBuilder:
def __init__(self, repo_context_factory: RuntimeRepoContextFactory | None = None) -> None:
self._repo_context_factory = repo_context_factory or RuntimeRepoContextFactory()
def build(
self,
*,
task_id: str,
dialog_session_id: str,
rag_session_id: str,
mode: str,
message: str,
attachments: list[dict],
files: list[dict],
progress_cb,
) -> TaskRuntimeContext:
files_map = self._files_to_map(files)
return TaskRuntimeContext(
task_id=task_id,
dialog_session_id=dialog_session_id,
rag_session_id=rag_session_id,
mode=mode,
message=message,
attachments=list(attachments or []),
files=list(files or []),
files_map=files_map,
progress_cb=progress_cb,
repo_context=self._repo_context_factory.build(files_map),
conversation_state=ConversationState(),
)
def _files_to_map(self, files: list[dict]) -> dict[str, dict]:
out: dict[str, dict] = {}
for item in files or []:
if isinstance(item, dict) and item.get("path"):
out[str(item["path"])] = dict(item)
return out
@@ -0,0 +1,37 @@
from __future__ import annotations
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
from app.modules.agent.task_runtime.workflows.base import TaskWorkflow
class TaskWorkflowDispatcher:
def __init__(
self,
*,
docs_qa: TaskWorkflow,
general_qa: TaskWorkflow,
docs_generation: TaskWorkflow,
openapi: TaskWorkflow,
fallback: TaskWorkflow,
) -> None:
self._docs_qa = docs_qa
self._general_qa = general_qa
self._docs_generation = docs_generation
self._openapi = openapi
self._fallback = fallback
def dispatch(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
workflow = self._select(getattr(ctx.route_result, "intent", "FALLBACK"))
return workflow.run(ctx)
def _select(self, intent: str) -> TaskWorkflow:
normalized = (intent or "FALLBACK").upper()
if normalized == "DOCUMENTATION_EXPLAIN":
return self._docs_qa
if normalized == "GENERAL_QA":
return self._general_qa
if normalized == "OPENAPI_GENERATION":
return self._openapi
if normalized == "GENERATE_DOCS_FROM_CODE":
return self._docs_generation
return self._fallback
@@ -0,0 +1,35 @@
from __future__ import annotations
from app.modules.agent.task_runtime.models import TaskRuntimeContext
class AttachmentContextProvider:
def build(self, ctx: TaskRuntimeContext) -> dict[str, object]:
return {"attachments": list(ctx.attachments)}
class ConfluenceContextProvider:
def build(self, ctx: TaskRuntimeContext) -> dict[str, object]:
urls = [
str(item.get("url"))
for item in ctx.attachments
if str(item.get("type") or "").lower() == "confluence_url" and item.get("url")
]
return {"confluence_urls": urls}
class ContextEnrichmentService:
def __init__(
self,
attachment_provider: AttachmentContextProvider | None = None,
confluence_provider: ConfluenceContextProvider | None = None,
) -> None:
self._attachment_provider = attachment_provider or AttachmentContextProvider()
self._confluence_provider = confluence_provider or ConfluenceContextProvider()
def enrich(self, ctx: TaskRuntimeContext) -> dict[str, object]:
enriched: dict[str, object] = {}
enriched.update(self._attachment_provider.build(ctx))
enriched.update(self._confluence_provider.build(ctx))
enriched["files"] = list(ctx.files)
return enriched
@@ -0,0 +1,92 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from app.modules.agent.intent_router_v2 import IntentRouterV2
from app.modules.agent.task_runtime.context import TaskRuntimeContextBuilder
from app.modules.agent.task_runtime.dispatcher import TaskWorkflowDispatcher
from app.modules.agent.task_runtime.enrichment import ContextEnrichmentService
from app.modules.agent.task_runtime.status_events import emit_status_block
class AgentTaskRuntimeFacade:
def __init__(
self,
*,
router: IntentRouterV2,
context_builder: TaskRuntimeContextBuilder,
enrichment: ContextEnrichmentService,
dispatcher: TaskWorkflowDispatcher,
) -> None:
self._router = router
self._context_builder = context_builder
self._enrichment = enrichment
self._dispatcher = dispatcher
async def run(
self,
*,
task_id: str,
dialog_session_id: str,
rag_session_id: str,
mode: str,
message: str,
attachments: list[dict],
files: list[dict],
progress_cb=None,
):
ctx = self._context_builder.build(
task_id=task_id,
dialog_session_id=dialog_session_id,
rag_session_id=rag_session_id,
mode=mode,
message=message,
attachments=attachments,
files=files,
progress_cb=progress_cb,
)
self._notify(progress_cb, "runtime.router", "Маршрутизирую запрос по task workflows.", {"mode": mode})
ctx.route_result = self._router.route(message, ctx.conversation_state, ctx.repo_context)
emit_status_block(
ctx,
block_id="intent_router",
title="Intent Router",
lines=[
f"intent: {ctx.route_result.intent}",
f"sub_intent: {ctx.route_result.query_plan.sub_intent}",
f"conversation_mode: {ctx.route_result.conversation_mode}",
f"matched_source: {ctx.route_result.matched_intent_source}",
],
)
self._notify(progress_cb, "runtime.context", "Собираю контекст для выбранного workflow.", {"intent": ctx.route_result.intent})
ctx.enriched_context = self._enrichment.enrich(ctx)
loop = asyncio.get_running_loop()
self._notify(progress_cb, "runtime.workflow", "Запускаю целевой task workflow.", {"intent": ctx.route_result.intent})
emit_status_block(
ctx,
block_id="workflow",
title="Task Workflow",
lines=[
f"intent: {ctx.route_result.intent}",
f"sub_intent: {ctx.route_result.query_plan.sub_intent}",
"dispatcher: task_workflow_dispatcher",
],
)
result = await loop.run_in_executor(None, lambda: self._dispatcher.dispatch(ctx))
return SimpleNamespace(
result_type=result.result_type,
answer=result.answer,
artifacts=result.artifacts,
changeset=[],
meta={
"task_id": task_id,
"intent": ctx.route_result.intent,
"workflow_meta": result.meta,
"route": ctx.route_result.model_dump(mode="json"),
},
)
def _notify(self, progress_cb, stage: str, message: str, meta: dict) -> None:
if progress_cb is not None:
progress_cb(stage, message, "task_progress", meta)
@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable
from app.schemas.chat import TaskArtifact, TaskResultType
ProgressCallback = Callable[[str, str, str, dict | None], Any]
@dataclass(slots=True)
class TaskRuntimeContext:
task_id: str
dialog_session_id: str
rag_session_id: str
mode: str
message: str
attachments: list[dict[str, Any]] = field(default_factory=list)
files: list[dict[str, Any]] = field(default_factory=list)
files_map: dict[str, dict[str, Any]] = field(default_factory=dict)
progress_cb: ProgressCallback | None = None
repo_context: Any = None
conversation_state: Any = None
route_result: Any = None
enriched_context: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class WorkflowExecutionResult:
result_type: TaskResultType
answer: str = ""
artifacts: list[TaskArtifact] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,28 @@
from __future__ import annotations
from app.modules.agent.task_runtime.models import TaskRuntimeContext
def emit_status_block(
ctx: TaskRuntimeContext,
*,
block_id: str,
title: str,
lines: list[str],
append: bool = False,
) -> None:
if ctx.progress_cb is None:
return
ctx.progress_cb(
f"status.{block_id}",
title,
"task_progress",
{
"status_block": {
"id": block_id,
"title": title,
"lines": [line for line in lines if str(line).strip()],
"append": append,
}
},
)
@@ -0,0 +1,31 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class DocumentationTemplate:
template_id: str
title: str
sections: tuple[str, ...]
class DocumentationTemplateRegistry:
def __init__(self) -> None:
self._templates = {
"system_analytics_v1": DocumentationTemplate(
template_id="system_analytics_v1",
title="Документация по системной аналитике",
sections=(
"Назначение",
"Контекст и границы",
"Основные сущности",
"Сценарии и workflow",
"Интеграции",
"Открытые вопросы и допущения",
),
)
}
def default(self) -> DocumentationTemplate:
return self._templates["system_analytics_v1"]
@@ -0,0 +1,11 @@
from app.modules.agent.task_runtime.workflows.docs_generation import DocumentationGenerationWorkflow
from app.modules.agent.task_runtime.workflows.docs_qa import DocsQaWorkflow
from app.modules.agent.task_runtime.workflows.fallback import FallbackWorkflow
from app.modules.agent.task_runtime.workflows.openapi import OpenApiWorkflow
__all__ = [
"DocsQaWorkflow",
"DocumentationGenerationWorkflow",
"FallbackWorkflow",
"OpenApiWorkflow",
]
@@ -0,0 +1,11 @@
from __future__ import annotations
from typing import Protocol
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
class TaskWorkflow(Protocol):
workflow_id: str
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult: ...
@@ -0,0 +1,82 @@
from __future__ import annotations
import json
from app.modules.agent.llm import AgentLlmService
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
from app.modules.agent.task_runtime.status_events import emit_status_block
from app.modules.agent.task_runtime.templates import DocumentationTemplateRegistry
from app.schemas.chat import TaskArtifact, TaskResultType
class DocumentationGenerationWorkflow:
workflow_id = "docs_generation"
def __init__(
self,
llm: AgentLlmService | None,
templates: DocumentationTemplateRegistry | None = None,
) -> None:
self._llm = llm
self._templates = templates or DocumentationTemplateRegistry()
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
template = self._templates.default()
emit_status_block(
ctx,
block_id="rag_retrieval",
title="RAG Retrieval",
lines=["not used in docs_generation workflow"],
)
answer = self._generate(ctx, template)
emit_status_block(
ctx,
block_id="workflow",
title="Task Workflow",
lines=[
f"workflow_id: {self.workflow_id}",
f"template_id: {template.template_id}",
],
)
emit_status_block(
ctx,
block_id="evidence_gate",
title="Evidence Gate",
lines=["not applied in docs_generation workflow"],
)
artifact = TaskArtifact(
artifact_type=TaskResultType.DOCUMENTATION,
title=template.title,
content=answer,
format="markdown",
template_id=template.template_id,
source_refs=list(ctx.enriched_context.get("confluence_urls") or []),
)
return WorkflowExecutionResult(
result_type=TaskResultType.DOCUMENTATION,
answer=answer,
artifacts=[artifact],
meta={"workflow_id": self.workflow_id, "intent": getattr(ctx.route_result, "intent", "")},
)
def _generate(self, ctx: TaskRuntimeContext, template) -> str:
if self._llm is None:
return self._fallback(template)
payload = json.dumps(
{
"question": ctx.message,
"template_id": template.template_id,
"title": template.title,
"sections": list(template.sections),
"attachments": list(ctx.attachments),
"files": list(ctx.files),
"context": dict(ctx.enriched_context),
},
ensure_ascii=False,
indent=2,
)
return self._llm.generate("docs_template_generation", payload, log_context="agent.workflow.docs_generation").strip()
def _fallback(self, template) -> str:
sections = "\n".join(f"## {name}\n\nTBD" for name in template.sections)
return f"# {template.title}\n\n{sections}\n"
@@ -0,0 +1,80 @@
from __future__ import annotations
from app.modules.agent.runtime.docs_qa_pipeline import DocsQAPipelineRunner
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
from app.modules.agent.task_runtime.status_events import emit_status_block
from app.schemas.chat import TaskResultType
class DocsQaWorkflow:
workflow_id = "docs_qa"
def __init__(self, runner: DocsQAPipelineRunner) -> None:
self._runner = runner
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
result = self._runner.run(
ctx.message,
ctx.rag_session_id,
conversation_state=ctx.conversation_state,
mode="full",
)
diagnostics = result.diagnostics.model_dump(mode="json")
emit_status_block(
ctx,
block_id="rag_retrieval",
title="RAG Retrieval",
lines=_retrieval_lines(diagnostics),
)
emit_status_block(
ctx,
block_id="workflow",
title="Task Workflow",
lines=[
f"workflow_id: {self.workflow_id}",
f"prompt: {result.prompt_name}",
f"answer_mode: {result.answer_mode}",
],
)
emit_status_block(
ctx,
block_id="evidence_gate",
title="Evidence Gate",
lines=_gate_lines(diagnostics),
)
return WorkflowExecutionResult(
result_type=TaskResultType.ANSWER,
answer=result.answer,
meta={
"workflow_id": self.workflow_id,
"intent": result.router_result.intent,
"diagnostics": diagnostics,
},
)
def _retrieval_lines(diagnostics: dict) -> list[str]:
lines = [
f"planned_layers: {', '.join(diagnostics.get('planned_layers') or []) or '-'}",
f"executed_layers: {', '.join(diagnostics.get('executed_layers') or []) or '-'}",
]
layer_diagnostics = dict(diagnostics.get("layer_diagnostics") or {})
for layer_id in diagnostics.get("executed_layers") or []:
info = dict(layer_diagnostics.get(layer_id) or {})
hits = info.get("hits", 0)
lines.append(f"{layer_id}: {hits} hits")
return lines
def _gate_lines(diagnostics: dict) -> list[str]:
lines = [
f"decision: {diagnostics.get('gate_decision') or '-'}",
f"reason: {diagnostics.get('gate_decision_reason') or '-'}",
]
missing = list(diagnostics.get("gate_missing_requirements") or [])
satisfied = list(diagnostics.get("gate_satisfied_requirements") or [])
if missing:
lines.append(f"missing: {', '.join(missing)}")
if satisfied:
lines.append(f"satisfied: {', '.join(satisfied)}")
return lines
@@ -0,0 +1,54 @@
from __future__ import annotations
import json
from app.modules.agent.llm import AgentLlmService
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
from app.modules.agent.task_runtime.status_events import emit_status_block
from app.schemas.chat import TaskResultType
class FallbackWorkflow:
workflow_id = "fallback"
def __init__(self, llm: AgentLlmService | None) -> None:
self._llm = llm
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
emit_status_block(
ctx,
block_id="rag_retrieval",
title="RAG Retrieval",
lines=["not used in fallback workflow"],
)
if self._llm is None:
answer = "Пока не удалось подобрать специализированный workflow для этого запроса."
else:
payload = json.dumps(
{
"question": ctx.message,
"intent": getattr(ctx.route_result, "intent", ""),
"attachments": list(ctx.attachments),
"confluence_urls": list(ctx.enriched_context.get("confluence_urls") or []),
},
ensure_ascii=False,
indent=2,
)
answer = self._llm.generate("docs_fallback_answer", payload, log_context="agent.workflow.fallback").strip()
emit_status_block(
ctx,
block_id="workflow",
title="Task Workflow",
lines=[f"workflow_id: {self.workflow_id}"],
)
emit_status_block(
ctx,
block_id="evidence_gate",
title="Evidence Gate",
lines=["not applied in fallback workflow"],
)
return WorkflowExecutionResult(
result_type=TaskResultType.ANSWER,
answer=answer,
meta={"workflow_id": self.workflow_id, "intent": getattr(ctx.route_result, "intent", "")},
)
@@ -0,0 +1,54 @@
from __future__ import annotations
from app.modules.agent.runtime.docs_qa_pipeline import DocsQAPipelineRunner
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
from app.modules.agent.task_runtime.status_events import emit_status_block
from app.modules.agent.task_runtime.workflows.docs_qa import _gate_lines, _retrieval_lines
from app.schemas.chat import TaskResultType
class GeneralQaWorkflow:
workflow_id = "general_qa"
def __init__(self, runner: DocsQAPipelineRunner) -> None:
self._runner = runner
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
result = self._runner.run(
ctx.message,
ctx.rag_session_id,
conversation_state=ctx.conversation_state,
mode="full",
)
diagnostics = result.diagnostics.model_dump(mode="json")
emit_status_block(
ctx,
block_id="rag_retrieval",
title="RAG Retrieval",
lines=_retrieval_lines(diagnostics),
)
emit_status_block(
ctx,
block_id="workflow",
title="Task Workflow",
lines=[
f"workflow_id: {self.workflow_id}",
f"prompt: {result.prompt_name}",
f"answer_mode: {result.answer_mode}",
],
)
emit_status_block(
ctx,
block_id="evidence_gate",
title="Evidence Gate",
lines=_gate_lines(diagnostics),
)
return WorkflowExecutionResult(
result_type=TaskResultType.ANSWER,
answer=result.answer,
meta={
"workflow_id": self.workflow_id,
"intent": result.router_result.intent,
"diagnostics": diagnostics,
},
)
@@ -0,0 +1,63 @@
from __future__ import annotations
from app.modules.agent.runtime.docs_qa_pipeline import DocsQAPipelineRunner
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
from app.modules.agent.task_runtime.status_events import emit_status_block
from app.schemas.chat import TaskArtifact, TaskResultType
from app.modules.agent.task_runtime.workflows.docs_qa import _gate_lines, _retrieval_lines
class OpenApiWorkflow:
workflow_id = "openapi_generation"
def __init__(self, runner: DocsQAPipelineRunner) -> None:
self._runner = runner
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
result = self._runner.run(
ctx.message,
ctx.rag_session_id,
conversation_state=ctx.conversation_state,
mode="full",
)
diagnostics = result.diagnostics.model_dump(mode="json")
emit_status_block(
ctx,
block_id="rag_retrieval",
title="RAG Retrieval",
lines=_retrieval_lines(diagnostics),
)
emit_status_block(
ctx,
block_id="workflow",
title="Task Workflow",
lines=[
f"workflow_id: {self.workflow_id}",
f"prompt: {result.prompt_name}",
f"answer_mode: {result.answer_mode}",
],
)
emit_status_block(
ctx,
block_id="evidence_gate",
title="Evidence Gate",
lines=_gate_lines(diagnostics),
)
content = (result.openapi_result.raw_yaml if result.openapi_result else "") or result.answer
artifact = TaskArtifact(
artifact_type=TaskResultType.OPENAPI,
title="OpenAPI Specification",
content=content,
format="yaml",
source_refs=list(result.diagnostics.doc_paths),
)
return WorkflowExecutionResult(
result_type=TaskResultType.OPENAPI,
answer=content,
artifacts=[artifact],
meta={
"workflow_id": self.workflow_id,
"intent": result.router_result.intent,
"diagnostics": diagnostics,
},
)
+3
View File
@@ -0,0 +1,3 @@
from app.modules.agent_api.module import AgentApiModule
__all__ = ["AgentApiModule"]
@@ -0,0 +1,38 @@
from __future__ import annotations
import asyncio
from app.modules.agent_api.domain.models.agent_request import AgentRequest
from app.modules.agent_api.infrastructure.ids.request_id_factory import RequestIdFactory
from app.modules.agent_api.infrastructure.stores.in_memory_request_store import InMemoryRequestStore
from app.modules.agent_api.application.session_service import SessionService
from app.modules.orchestration.facade import OrchestrationFacade
class RequestService:
def __init__(
self,
request_store: InMemoryRequestStore,
request_ids: RequestIdFactory,
sessions: SessionService,
orchestration: OrchestrationFacade,
) -> None:
self._request_store = request_store
self._request_ids = request_ids
self._sessions = sessions
self._orchestration = orchestration
async def create(self, session_id: str, message: str, process_version: str) -> AgentRequest:
session = self._sessions.get(session_id)
request = AgentRequest.create(
request_id=self._request_ids.create(),
session_id=session_id,
message=message,
process_version=process_version,
)
self._request_store.save(request)
asyncio.create_task(self._orchestration.run(request, session))
return request
def get(self, request_id: str) -> AgentRequest | None:
return self._request_store.get(request_id)
@@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime, timezone
from app.core.exceptions import AppError
from app.modules.agent_api.domain.models.agent_session import AgentSession
from app.modules.agent_api.infrastructure.ids.session_id_factory import SessionIdFactory
from app.modules.agent_api.infrastructure.stores.in_memory_session_store import InMemorySessionStore
from app.schemas.common import ModuleName
class SessionService:
def __init__(
self,
store: InMemorySessionStore,
ids: SessionIdFactory,
rag_session_exists,
) -> None:
self._store = store
self._ids = ids
self._rag_session_exists = rag_session_exists
def create(self) -> AgentSession:
session = AgentSession.create(self._ids.create())
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 bind_rag_session(self, session_id: str, rag_session_id: str) -> AgentSession:
if not self._rag_session_exists(rag_session_id):
raise AppError("rag_session_not_found", f"RAG session not found: {rag_session_id}", ModuleName.RAG)
session = self.get(session_id)
session.active_rag_session_id = rag_session_id
session.updated_at = datetime.now(timezone.utc)
return self._store.save(session)
def reset(self, session_id: str) -> AgentSession:
session = self.get(session_id)
session.active_rag_session_id = None
session.updated_at = datetime.now(timezone.utc)
return self._store.save(session)
@@ -0,0 +1,24 @@
from __future__ import annotations
from app.core.exceptions import AppError
from app.modules.agent_api.infrastructure.streaming.sse_encoder import SseEncoder
from app.modules.agent_api.infrastructure.streaming.sse_event_channel import SseEventChannel
from app.schemas.common import ModuleName
class StreamService:
def __init__(self, channel: SseEventChannel, request_exists, encoder: SseEncoder | None = None) -> None:
self._channel = channel
self._request_exists = request_exists
self._encoder = encoder or SseEncoder()
async def subscribe(self, request_id: str):
if not self._request_exists(request_id):
raise AppError("request_not_found", f"Agent request not found: {request_id}", ModuleName.BACKEND)
return await self._channel.subscribe(request_id, replay=True)
async def unsubscribe(self, request_id: str, queue) -> None:
await self._channel.unsubscribe(request_id, queue)
def encode(self, event) -> str:
return self._encoder.encode(event)
@@ -0,0 +1,35 @@
from __future__ import annotations
from app.core.exceptions import AppError
from app.modules.agent_api.application.request_service import RequestService
from app.schemas.agent_api import AgentRequestCreateRequest, AgentRequestQueuedResponse, AgentRequestStateResponse
from app.schemas.common import ModuleName
class RequestController:
def __init__(self, service: RequestService) -> None:
self._service = service
async def create_request(self, request: AgentRequestCreateRequest) -> AgentRequestQueuedResponse:
item = await self._service.create(request.session_id, request.message, request.process_version)
return AgentRequestQueuedResponse(
request_id=item.request_id,
session_id=item.session_id,
status=item.status.value,
stream_url=f"/api/agent/streams/{item.request_id}",
)
def get_request(self, request_id: str) -> AgentRequestStateResponse:
item = self._service.get(request_id)
if item is None:
raise AppError("request_not_found", f"Agent request not found: {request_id}", ModuleName.BACKEND)
return AgentRequestStateResponse(
request_id=item.request_id,
session_id=item.session_id,
status=item.status.value,
process_version=item.process_version,
answer=item.answer,
error=item.error,
created_at=item.created_at,
completed_at=item.completed_at,
)
@@ -0,0 +1,39 @@
from __future__ import annotations
from app.schemas.agent_api import (
BindRagSessionRequest,
BindRagSessionResponse,
CreateAgentSessionResponse,
ResetAgentSessionResponse,
)
from app.modules.agent_api.application.session_service import SessionService
class SessionController:
def __init__(self, service: SessionService) -> None:
self._service = service
def create_session(self) -> CreateAgentSessionResponse:
session = self._service.create()
return CreateAgentSessionResponse(
session_id=session.session_id,
active_rag_session_id=session.active_rag_session_id,
created_at=session.created_at,
)
def bind_rag_session(self, session_id: str, request: BindRagSessionRequest) -> BindRagSessionResponse:
session = self._service.bind_rag_session(session_id, request.rag_session_id)
return BindRagSessionResponse(
session_id=session.session_id,
active_rag_session_id=session.active_rag_session_id,
updated_at=session.updated_at,
)
def reset_session(self, session_id: str) -> ResetAgentSessionResponse:
session = self._service.reset(session_id)
return ResetAgentSessionResponse(
session_id=session.session_id,
active_rag_session_id=session.active_rag_session_id,
status="reset",
updated_at=session.updated_at,
)
@@ -0,0 +1,37 @@
from __future__ import annotations
from fastapi.responses import StreamingResponse
from app.modules.agent_api.application.stream_service import StreamService
class StreamController:
def __init__(self, service: StreamService) -> None:
self._service = service
async def stream(self, request_id: str) -> StreamingResponse:
queue = await self._service.subscribe(request_id)
async def event_stream():
import asyncio
heartbeat = 10
try:
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=heartbeat)
yield self._service.encode(event)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
finally:
await self._service.unsubscribe(request_id, queue)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,18 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from uuid import uuid4
from app.schemas.client_events import ClientEventType
@dataclass(slots=True)
class ClientEventRecord:
request_id: str
type: ClientEventType
source: str
text: str = ""
payload: dict = field(default_factory=dict)
event_id: str = field(default_factory=lambda: f"evt_{uuid4().hex}")
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -0,0 +1,37 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from app.schemas.common import ErrorPayload
from app.schemas.orchestration import RequestExecutionStatus
@dataclass(slots=True)
class AgentRequest:
request_id: str
session_id: str
message: str
process_version: str
status: RequestExecutionStatus
created_at: datetime
completed_at: datetime | None = None
answer: str | None = None
error: ErrorPayload | None = None
@classmethod
def create(
cls,
request_id: str,
session_id: str,
message: str,
process_version: str,
) -> "AgentRequest":
return cls(
request_id=request_id,
session_id=session_id,
message=message,
process_version=process_version,
status=RequestExecutionStatus.QUEUED,
created_at=datetime.now(timezone.utc),
)
@@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
@dataclass(slots=True)
class AgentSession:
session_id: str
active_rag_session_id: str | None
created_at: datetime
updated_at: datetime
@classmethod
def create(cls, session_id: str) -> "AgentSession":
now = datetime.now(timezone.utc)
return cls(
session_id=session_id,
active_rag_session_id=None,
created_at=now,
updated_at=now,
)
@@ -0,0 +1,6 @@
from uuid import uuid4
class RequestIdFactory:
def create(self) -> str:
return f"req_{uuid4().hex}"
@@ -0,0 +1,6 @@
from uuid import uuid4
class SessionIdFactory:
def create(self) -> str:
return f"as_{uuid4().hex}"
@@ -0,0 +1,81 @@
from __future__ import annotations
from pathlib import Path
from threading import Lock
from app.modules.agent_api.domain.events.client_event import ClientEventRecord
from app.modules.agent_api.domain.models.agent_request import AgentRequest
from app.modules.agent_api.domain.models.agent_session import AgentSession
from app.modules.agent_api.infrastructure.logging.trace_file_path_builder import TraceFilePathBuilder
from app.modules.agent_api.infrastructure.logging.trace_markdown_writer import TraceMarkdownWriter
class RequestTraceLogger:
def __init__(self, root: Path) -> None:
self._paths = TraceFilePathBuilder(root)
self._writer = TraceMarkdownWriter()
self._files: dict[str, Path] = {}
self._lock = Lock()
def start_request(self, request: AgentRequest, session: AgentSession) -> None:
path = self._paths.build(request.request_id)
self._writer.initialize(
path,
[
f"# Request Trace: {request.request_id}",
"",
f"- session_id: {session.session_id}",
f"- active_rag_session_id: {session.active_rag_session_id or ''}",
f"- process_version: {request.process_version}",
f"- created_at: {request.created_at.isoformat()}",
"",
"## User Message",
request.message,
],
)
with self._lock:
self._files[request.request_id] = path
def log_step(self, request_id: str, step: str, status: str, details: dict | None = None) -> None:
self._append(request_id, f"Step {step}", {"status": status, "details": details or {}})
def log_event(self, event: ClientEventRecord) -> None:
self._append(
event.request_id,
f"Event {event.type.value}",
{
"source": event.source,
"text": event.text,
"payload": event.payload,
"created_at": event.created_at.isoformat(),
},
)
def complete_request(self, request: AgentRequest) -> None:
self._append(
request.request_id,
"Result",
{
"status": request.status.value,
"answer": request.answer,
"completed_at": request.completed_at.isoformat() if request.completed_at else None,
},
)
def fail_request(self, request: AgentRequest) -> None:
self._append(
request.request_id,
"Error",
{
"status": request.status.value,
"error": request.error.model_dump(mode="json") if request.error else None,
"completed_at": request.completed_at.isoformat() if request.completed_at else None,
},
)
def _append(self, request_id: str, title: str, payload: dict) -> None:
with self._lock:
path = self._files.get(request_id)
if path is None:
return
self._writer.append_section(path, title, payload)
@@ -0,0 +1,15 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
class TraceFilePathBuilder:
def __init__(self, root: Path) -> None:
self._root = root
def build(self, request_id: str) -> Path:
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
directory = self._root / stamp
directory.mkdir(parents=True, exist_ok=True)
return directory / f"{request_id}.md"
@@ -0,0 +1,20 @@
from __future__ import annotations
import json
from pathlib import Path
class TraceMarkdownWriter:
def initialize(self, path: Path, lines: list[str]) -> None:
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def append_section(self, path: Path, title: str, payload: dict | list | str) -> None:
body = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False, indent=2)
with path.open("a", encoding="utf-8") as handle:
handle.write(f"\n## {title}\n")
if isinstance(payload, str):
handle.write(body + "\n")
else:
handle.write("```json\n")
handle.write(body + "\n")
handle.write("```\n")
@@ -0,0 +1,20 @@
from __future__ import annotations
from threading import Lock
from app.modules.agent_api.domain.models.agent_request import AgentRequest
class InMemoryRequestStore:
def __init__(self) -> None:
self._items: dict[str, AgentRequest] = {}
self._lock = Lock()
def save(self, request: AgentRequest) -> AgentRequest:
with self._lock:
self._items[request.request_id] = request
return request
def get(self, request_id: str) -> AgentRequest | None:
with self._lock:
return self._items.get(request_id)
@@ -0,0 +1,20 @@
from __future__ import annotations
from threading import Lock
from app.modules.agent_api.domain.models.agent_session import AgentSession
class InMemorySessionStore:
def __init__(self) -> None:
self._items: dict[str, AgentSession] = {}
self._lock = Lock()
def save(self, session: AgentSession) -> AgentSession:
with self._lock:
self._items[session.session_id] = session
return session
def get(self, session_id: str) -> AgentSession | None:
with self._lock:
return self._items.get(session_id)
@@ -0,0 +1,20 @@
from __future__ import annotations
from collections import defaultdict
from app.modules.agent_api.domain.events.client_event import ClientEventRecord
class ReplayBuffer:
def __init__(self, limit: int = 200) -> None:
self._limit = limit
self._items: dict[str, list[ClientEventRecord]] = defaultdict(list)
def append(self, request_id: str, event: ClientEventRecord) -> None:
history = self._items[request_id]
history.append(event)
if len(history) > self._limit:
del history[: len(history) - self._limit]
def list(self, request_id: str) -> list[ClientEventRecord]:
return list(self._items.get(request_id, []))
@@ -0,0 +1,19 @@
from __future__ import annotations
import json
from app.modules.agent_api.domain.events.client_event import ClientEventRecord
class SseEncoder:
def encode(self, event: ClientEventRecord) -> str:
payload = {
"event_id": event.event_id,
"request_id": event.request_id,
"type": event.type.value,
"source": event.source,
"text": event.text,
"payload": event.payload,
"created_at": event.created_at.isoformat(),
}
return f"event: {event.type.value}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
@@ -0,0 +1,42 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from app.modules.agent_api.domain.events.client_event import ClientEventRecord
from app.modules.agent_api.infrastructure.streaming.replay_buffer import ReplayBuffer
class SseEventChannel:
def __init__(self, replay_buffer: ReplayBuffer | None = None) -> None:
self._channels: dict[str, list[asyncio.Queue[ClientEventRecord]]] = defaultdict(list)
self._replay = replay_buffer or ReplayBuffer()
self._lock = asyncio.Lock()
async def subscribe(self, request_id: str, replay: bool = True) -> asyncio.Queue[ClientEventRecord]:
queue: asyncio.Queue[ClientEventRecord] = asyncio.Queue()
snapshot: list[ClientEventRecord] = []
async with self._lock:
self._channels[request_id].append(queue)
if replay:
snapshot = self._replay.list(request_id)
for item in snapshot:
await queue.put(item)
return queue
async def unsubscribe(self, request_id: str, queue: asyncio.Queue[ClientEventRecord]) -> None:
async with self._lock:
queues = self._channels.get(request_id)
if not queues:
return
if queue in queues:
queues.remove(queue)
if not queues:
self._channels.pop(request_id, None)
async def publish(self, event: ClientEventRecord) -> None:
async with self._lock:
self._replay.append(event.request_id, event)
queues = list(self._channels.get(event.request_id, []))
for queue in queues:
await queue.put(event)
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from fastapi import APIRouter
from app.modules.agent_api.application.request_service import RequestService
from app.modules.agent_api.application.session_service import SessionService
from app.modules.agent_api.application.stream_service import StreamService
from app.modules.agent_api.controllers.request_controller import RequestController
from app.modules.agent_api.controllers.session_controller import SessionController
from app.modules.agent_api.controllers.stream_controller import StreamController
from app.modules.agent_api.public_router import build_public_router
class AgentApiModule:
def __init__(
self,
sessions: SessionService,
requests: RequestService,
streams: StreamService,
) -> None:
self._sessions = SessionController(sessions)
self._requests = RequestController(requests)
self._streams = StreamController(streams)
def public_router(self) -> APIRouter:
return build_public_router(
sessions=self._sessions,
requests=self._requests,
streams=self._streams,
)
@@ -0,0 +1,50 @@
from __future__ import annotations
from fastapi import APIRouter
from app.modules.agent_api.controllers.request_controller import RequestController
from app.modules.agent_api.controllers.session_controller import SessionController
from app.modules.agent_api.controllers.stream_controller import StreamController
from app.schemas.agent_api import (
AgentRequestCreateRequest,
AgentRequestQueuedResponse,
AgentRequestStateResponse,
BindRagSessionRequest,
BindRagSessionResponse,
CreateAgentSessionResponse,
ResetAgentSessionResponse,
)
def build_public_router(
sessions: SessionController,
requests: RequestController,
streams: StreamController,
) -> APIRouter:
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)
@router.post("/api/agent/requests", response_model=AgentRequestQueuedResponse)
async def create_request(request: AgentRequestCreateRequest) -> AgentRequestQueuedResponse:
return await requests.create_request(request)
@router.get("/api/agent/requests/{request_id}", response_model=AgentRequestStateResponse)
async def get_request(request_id: str) -> AgentRequestStateResponse:
return requests.get_request(request_id)
@router.get("/api/agent/streams/{request_id}")
async def stream(request_id: str):
return await streams.stream(request_id)
return router
+95 -34
View File
@@ -1,16 +1,46 @@
from app.modules.agent.runtime import AgentRuntimeExecutor, RuntimeRetrievalAdapter
from app.modules.agent.runtime.code_qa_runner_adapter import CodeQaRunnerAdapter
from pathlib import Path
from app.modules.agent.llm import AgentLlmService
from app.modules.agent.llm.prompt_loader import PromptLoader
from app.modules.chat.direct_service import CodeExplainChatService
from app.modules.chat.dialog_store import DialogSessionStore
from app.modules.chat.repository import ChatRepository
from app.modules.chat.module import ChatModule
from app.modules.chat.session_resolver import ChatSessionResolver
from app.modules.chat.task_store import TaskStore
from app.modules.agent.intent_router_v2 import IntentRouterV2
from app.modules.agent.runtime import DocsQAPipelineRunner
from app.modules.agent.runtime.steps.retrieval import RuntimeRepoContextFactory, RuntimeRetrievalAdapter
from app.modules.agent.task_runtime import ContextEnrichmentService, DocumentationTemplateRegistry, TaskRuntimeContextBuilder
from app.modules.agent.task_runtime.workflows import (
DocumentationGenerationWorkflow,
DocsQaWorkflow,
FallbackWorkflow,
OpenApiWorkflow,
)
from app.modules.agent.task_runtime.workflows.general_qa import GeneralQaWorkflow
from app.modules.agent_api import AgentApiModule
from app.modules.agent_api.application.request_service import RequestService
from app.modules.agent_api.application.session_service import SessionService
from app.modules.agent_api.application.stream_service import StreamService
from app.modules.agent_api.infrastructure.ids.request_id_factory import RequestIdFactory
from app.modules.agent_api.infrastructure.ids.session_id_factory import SessionIdFactory
from app.modules.agent_api.infrastructure.logging.request_trace_logger import RequestTraceLogger
from app.modules.agent_api.infrastructure.stores.in_memory_request_store import InMemoryRequestStore
from app.modules.agent_api.infrastructure.stores.in_memory_session_store import InMemorySessionStore
from app.modules.agent_api.infrastructure.streaming.sse_event_channel import SseEventChannel
from app.modules.orchestration import OrchestrationFacade
from app.modules.orchestration.adapters.intent_router_adapter import IntentRouterAdapter
from app.modules.orchestration.adapters.llm_chat_adapter import LlmChatAdapter
from app.modules.orchestration.messaging.client_message_publisher import ClientMessagePublisher
from app.modules.orchestration.processes.registry import ProcessRegistry
from app.modules.orchestration.processes.v1.process import V1Process
from app.modules.orchestration.processes.v1.steps.bootstrap_step import BootstrapStep
from app.modules.orchestration.processes.v1.steps.finalize_step import FinalizeStep
from app.modules.orchestration.processes.v1.steps.run_llm_step import RunLlmStep
from app.modules.orchestration.processes.v2.process import V2Process
from app.modules.orchestration.processes.v2.steps.execute_documentation_workflow_step import ExecuteDocumentationWorkflowStep
from app.modules.orchestration.processes.v2.steps.execute_fallback_workflow_step import ExecuteFallbackWorkflowStep
from app.modules.orchestration.processes.v2.steps.execute_general_qa_workflow_step import ExecuteGeneralQaWorkflowStep
from app.modules.orchestration.processes.v2.steps.execute_openapi_workflow_step import ExecuteOpenApiWorkflowStep
from app.modules.orchestration.processes.v2.steps.route_intent_step import RouteIntentStep
from app.modules.orchestration.runtime.process_runner import ProcessRunner
from app.modules.rag.persistence.repository import RagRepository
from app.modules.agent.runtime.story_context_repository import StoryContextRepository, StoryContextSchemaRepository
from app.modules.agent.runtime.steps.explain import CodeExplainRetrieverV2, CodeGraphRepository, LayeredRetrievalGateway
from app.modules.rag.module import RagModule, RagRepoModule
from app.modules.shared.bootstrap import bootstrap_database
from app.modules.shared.event_bus import EventBus
@@ -22,20 +52,14 @@ class ModularApplication:
self.events = EventBus()
self.retry = RetryExecutor()
self.rag_repository = RagRepository()
self.chat_repository = ChatRepository()
self.story_context_schema_repository = StoryContextSchemaRepository()
self.story_context_repository = StoryContextRepository()
self.chat_tasks = TaskStore()
self.rag = RagModule(event_bus=self.events, retry=self.retry, repository=self.rag_repository)
self.rag_repo = RagRepoModule(
story_context_repository=self.story_context_repository,
rag_repository=self.rag_repository,
)
self.code_explain_retriever = CodeExplainRetrieverV2(
gateway=LayeredRetrievalGateway(self.rag_repository, self.rag.embedder),
graph_repository=CodeGraphRepository(),
)
from app.modules.shared.gigachat.client import GigaChatClient
from app.modules.shared.gigachat.settings import GigaChatSettings
from app.modules.shared.gigachat.token_provider import GigaChatTokenProvider
@@ -44,32 +68,69 @@ class ModularApplication:
_giga_client = GigaChatClient(_giga_settings, GigaChatTokenProvider(_giga_settings))
_prompt_loader = PromptLoader()
self._agent_llm = AgentLlmService(client=_giga_client, prompts=_prompt_loader)
_router = IntentRouterV2()
_retrieval = RuntimeRetrievalAdapter(self.rag_repository)
_executor = AgentRuntimeExecutor(llm=self._agent_llm, retrieval=_retrieval)
self._agent_runner = CodeQaRunnerAdapter(_executor)
self.direct_chat = CodeExplainChatService(
retriever=self.code_explain_retriever,
_repo_context_factory = RuntimeRepoContextFactory()
_docs_runner = DocsQAPipelineRunner(
router=_router,
retrieval_adapter=_retrieval,
repo_context=_repo_context_factory.build(),
llm=self._agent_llm,
session_resolver=ChatSessionResolver(
dialogs=DialogSessionStore(self.chat_repository),
rag_session_exists=lambda rag_session_id: self.rag.sessions.get(rag_session_id) is not None,
),
task_store=self.chat_tasks,
message_sink=self.chat_repository.add_message,
)
self.chat = ChatModule(
agent_runner=self._agent_runner,
event_bus=self.events,
retry=self.retry,
rag_sessions=self.rag.sessions,
repository=self.chat_repository,
direct_chat=self.direct_chat,
task_store=self.chat_tasks,
_task_context_builder = TaskRuntimeContextBuilder(_repo_context_factory)
_context_enrichment = ContextEnrichmentService()
_docs_workflow = DocsQaWorkflow(_docs_runner)
_openapi_workflow = OpenApiWorkflow(_docs_runner)
_general_qa_workflow = GeneralQaWorkflow(_docs_runner)
_fallback_workflow = FallbackWorkflow(self._agent_llm)
_docs_generation_workflow = DocumentationGenerationWorkflow(self._agent_llm, DocumentationTemplateRegistry())
self._docs_generation_workflow = _docs_generation_workflow
self.agent_sessions = InMemorySessionStore()
self.agent_requests = InMemoryRequestStore()
self.agent_events = SseEventChannel()
self.agent_trace_logger = RequestTraceLogger(Path("runtime_traces/agent_requests"))
_publisher = ClientMessagePublisher(self.agent_events, self.agent_trace_logger)
_process_registry = ProcessRegistry(
V1Process([BootstrapStep(), RunLlmStep(LlmChatAdapter(self._agent_llm)), FinalizeStep()]),
V2Process(
[
BootstrapStep(),
RouteIntentStep(IntentRouterAdapter(_router), _task_context_builder, _context_enrichment),
ExecuteDocumentationWorkflowStep(_docs_workflow, "workflow_documentation_explain"),
ExecuteOpenApiWorkflowStep(_openapi_workflow, "workflow_openapi_generation"),
ExecuteGeneralQaWorkflowStep(_general_qa_workflow, "workflow_general_qa"),
ExecuteFallbackWorkflowStep(_fallback_workflow, "workflow_fallback"),
FinalizeStep(),
]
),
)
_orchestration = OrchestrationFacade(
request_store=self.agent_requests,
process_registry=_process_registry,
process_runner=ProcessRunner(),
publisher=_publisher,
trace_logger=self.agent_trace_logger,
)
_session_service = SessionService(
store=self.agent_sessions,
ids=SessionIdFactory(),
rag_session_exists=lambda rag_session_id: self.rag.sessions.get(rag_session_id) is not None,
)
_request_service = RequestService(
request_store=self.agent_requests,
request_ids=RequestIdFactory(),
sessions=_session_service,
orchestration=_orchestration,
)
self.agent_api = AgentApiModule(
sessions=_session_service,
requests=_request_service,
streams=StreamService(self.agent_events, request_exists=lambda request_id: self.agent_requests.get(request_id) is not None),
)
def startup(self) -> None:
bootstrap_database(
self.rag_repository,
self.chat_repository,
self.story_context_schema_repository,
)
+1 -7
View File
@@ -1,13 +1,11 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from fastapi import APIRouter, Header
from fastapi.responses import StreamingResponse
from app.core.exceptions import AppError
from app.modules.chat.direct_service import CodeExplainChatService
from app.modules.chat.dialog_store import DialogSessionStore
from app.modules.chat.service import ChatOrchestrator
from app.modules.chat.task_store import TaskStore
@@ -37,16 +35,13 @@ class ChatModule:
retry: RetryExecutor,
rag_sessions: RagSessionStore,
repository: ChatRepository,
direct_chat: CodeExplainChatService | None = None,
task_store: TaskStore | None = None,
) -> None:
self._rag_sessions = rag_sessions
self._simple_code_explain_only = os.getenv("SIMPLE_CODE_EXPLAIN_ONLY", "true").lower() in {"1", "true", "yes"}
self.tasks = task_store or TaskStore()
self.dialogs = DialogSessionStore(repository)
self.idempotency = IdempotencyStore()
self.events = event_bus
self.direct_chat = direct_chat
self.chat = ChatOrchestrator(
task_store=self.tasks,
dialogs=self.dialogs,
@@ -76,8 +71,6 @@ class ChatModule:
request: ChatMessageRequest,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> TaskQueuedResponse | TaskResultResponse:
if self._simple_code_explain_only and self.direct_chat is not None:
return await self.direct_chat.handle_message(request)
task = await self.chat.enqueue_message(request, idempotency_key)
return TaskQueuedResponse(task_id=task.task_id, status=task.status.value)
@@ -91,6 +84,7 @@ class ChatModule:
status=task.status,
result_type=task.result_type,
answer=task.answer,
artifacts=task.artifacts,
changeset=task.changeset,
error=task.error,
)
+9 -3
View File
@@ -134,15 +134,20 @@ class ChatOrchestrator:
task.status = TaskStatus.DONE
task.result_type = TaskResultType(result.result_type)
task.answer = result.answer
task.artifacts = list(getattr(result, "artifacts", []) or [])
task.changeset = result.changeset
if task.result_type == TaskResultType.ANSWER and task.answer:
self._message_sink(dialog_session_id, "assistant", task.answer, task_id=task_id)
if task.result_type != TaskResultType.CHANGESET and (task.answer or task.artifacts):
payload = {
"result_type": task.result_type.value,
"artifacts": [item.model_dump(mode="json") for item in task.artifacts],
}
self._message_sink(dialog_session_id, "assistant", task.answer or "", task_id=task_id, payload=payload)
LOGGER.warning(
"outgoing chat response: task_id=%s dialog_session_id=%s result_type=%s answer=%s",
task_id,
dialog_session_id,
task.result_type.value,
_truncate_for_log(task.answer),
_truncate_for_log(task.answer or ""),
)
elif task.result_type == TaskResultType.CHANGESET:
self._message_sink(
@@ -172,6 +177,7 @@ class ChatOrchestrator:
"status": task.status.value,
"result_type": task.result_type.value,
"answer": task.answer,
"artifacts": [item.model_dump(mode="json") for item in task.artifacts],
"changeset": [item.model_dump(mode="json") for item in task.changeset],
"meta": getattr(result, "meta", {}) or {},
},
+2 -1
View File
@@ -3,7 +3,7 @@ from threading import Lock
from uuid import uuid4
from app.schemas.changeset import ChangeItem
from app.schemas.chat import TaskResultType, TaskStatus
from app.schemas.chat import TaskArtifact, TaskResultType, TaskStatus
from app.schemas.common import ErrorPayload
@@ -13,6 +13,7 @@ class TaskState:
status: TaskStatus = TaskStatus.QUEUED
result_type: TaskResultType | None = None
answer: str | None = None
artifacts: list[TaskArtifact] = field(default_factory=list)
changeset: list[ChangeItem] = field(default_factory=list)
error: ErrorPayload | None = None
+2 -1
View File
@@ -2,12 +2,13 @@ from typing import Protocol
from collections.abc import Awaitable, Callable
from app.schemas.changeset import ChangeItem
from app.schemas.chat import TaskResultType
from app.schemas.chat import TaskArtifact, TaskResultType
class AgentRunResult(Protocol):
result_type: TaskResultType
answer: str | None
artifacts: list[TaskArtifact]
changeset: list[ChangeItem]
meta: dict
@@ -0,0 +1,3 @@
from app.modules.orchestration.facade import OrchestrationFacade
__all__ = ["OrchestrationFacade"]
@@ -0,0 +1,11 @@
from __future__ import annotations
from app.modules.agent.intent_router_v2 import IntentRouterV2
class IntentRouterAdapter:
def __init__(self, router: IntentRouterV2) -> None:
self._router = router
def route(self, user_query: str, conversation_state, repo_context):
return self._router.route(user_query, conversation_state, repo_context)
@@ -0,0 +1,19 @@
from __future__ import annotations
import asyncio
from app.modules.agent.llm.service import AgentLlmService
class LlmChatAdapter:
def __init__(self, llm: AgentLlmService, prompt_name: str = "agent_api_v1") -> None:
self._llm = llm
self._prompt_name = prompt_name
async def generate(self, message: str, request_id: str) -> str:
return await asyncio.to_thread(
self._llm.generate,
self._prompt_name,
message,
log_context=f"agent_api:{request_id}",
)
@@ -0,0 +1,44 @@
from __future__ import annotations
from types import SimpleNamespace
from app.core.exceptions import AppError
from app.modules.agent.task_runtime.facade import AgentTaskRuntimeFacade
from app.modules.agent_api.domain.models.agent_session import AgentSession
from app.modules.orchestration.context.execution_context import ExecutionContext
from app.schemas.common import ModuleName
class TaskRuntimeAdapter:
def __init__(self, runtime: AgentTaskRuntimeFacade) -> None:
self._runtime = runtime
async def run(self, context: ExecutionContext) -> SimpleNamespace:
rag_session_id = context.session.active_rag_session_id
if not rag_session_id:
raise AppError(
"rag_session_not_bound",
"Agent session has no active rag_session_id for process v2.",
ModuleName.RAG,
)
def progress_cb(stage: str, message: str, kind: str = "task_progress", meta: dict | None = None):
payload = dict(meta or {})
payload.setdefault("kind", kind)
return context.publisher.publish_status(
context.request.request_id,
stage,
message,
payload,
)
return await self._runtime.run(
task_id=context.request.request_id,
dialog_session_id=context.session.session_id,
rag_session_id=rag_session_id,
mode="auto",
message=context.request.message,
attachments=[],
files=[],
progress_cb=progress_cb,
)
@@ -0,0 +1,20 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from app.modules.agent_api.domain.models.agent_request import AgentRequest
from app.modules.agent_api.domain.models.agent_session import AgentSession
from app.modules.agent_api.infrastructure.logging.request_trace_logger import RequestTraceLogger
from app.modules.orchestration.messaging.client_message_publisher import ClientMessagePublisher
@dataclass(slots=True)
class ExecutionContext:
request: AgentRequest
session: AgentSession
publisher: ClientMessagePublisher
trace_logger: RequestTraceLogger
task_context: Any = None
route_result: Any = None
workflow_result: Any = None
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import datetime, timezone
from app.core.exceptions import AppError
from app.modules.agent_api.domain.models.agent_request import AgentRequest
from app.modules.agent_api.domain.models.agent_session import AgentSession
from app.modules.agent_api.infrastructure.logging.request_trace_logger import RequestTraceLogger
from app.modules.agent_api.infrastructure.stores.in_memory_request_store import InMemoryRequestStore
from app.modules.orchestration.context.execution_context import ExecutionContext
from app.modules.orchestration.messaging.client_message_publisher import ClientMessagePublisher
from app.modules.orchestration.processes.registry import ProcessRegistry
from app.modules.orchestration.runtime.process_runner import ProcessRunner
from app.schemas.common import ErrorPayload, ModuleName
from app.schemas.orchestration import RequestExecutionStatus
class OrchestrationFacade:
def __init__(
self,
request_store: InMemoryRequestStore,
process_registry: ProcessRegistry,
process_runner: ProcessRunner,
publisher: ClientMessagePublisher,
trace_logger: RequestTraceLogger,
) -> None:
self._request_store = request_store
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._process_registry.get(request.process_version)
if process is None:
raise AppError("process_not_found", f"Unsupported process version: {request.process_version}", ModuleName.AGENT)
request.status = RequestExecutionStatus.RUNNING
self._request_store.save(request)
self._trace_logger.start_request(request, session)
context = ExecutionContext(
request=request,
session=session,
publisher=self._publisher,
trace_logger=self._trace_logger,
)
await self._process_runner.run(context, process.steps())
request.status = RequestExecutionStatus.DONE
request.completed_at = datetime.now(timezone.utc)
self._request_store.save(request)
self._trace_logger.complete_request(request)
except Exception as exc:
request.status = RequestExecutionStatus.ERROR
request.completed_at = datetime.now(timezone.utc)
if isinstance(exc, AppError):
request.error = ErrorPayload(code=exc.code, desc=exc.desc, module=exc.module)
else:
request.error = ErrorPayload(
code="agent_api_runtime_error",
desc="Agent request failed unexpectedly.",
module=ModuleName.AGENT,
)
self._request_store.save(request)
self._trace_logger.fail_request(request)
await self._publisher.publish_status(
request.request_id,
"orchestrator",
"Во время обработки запроса произошла ошибка.",
{"code": request.error.code},
)
@@ -0,0 +1,24 @@
from __future__ import annotations
from app.modules.agent_api.infrastructure.logging.request_trace_logger import RequestTraceLogger
from app.modules.agent_api.infrastructure.streaming.sse_event_channel import SseEventChannel
from app.modules.orchestration.messaging.status_message_factory import StatusMessageFactory
from app.modules.orchestration.messaging.user_message_factory import UserMessageFactory
class ClientMessagePublisher:
def __init__(self, channel: SseEventChannel, trace_logger: RequestTraceLogger) -> None:
self._channel = channel
self._trace_logger = trace_logger
self._status = StatusMessageFactory()
self._user = UserMessageFactory()
async def publish_status(self, request_id: str, source: str, text: str, payload: dict | None = None) -> None:
event = self._status.create(request_id, source, text, payload)
self._trace_logger.log_event(event)
await self._channel.publish(event)
async def publish_user(self, request_id: str, source: str, text: str, payload: dict | None = None) -> None:
event = self._user.create(request_id, source, text, payload)
self._trace_logger.log_event(event)
await self._channel.publish(event)
@@ -0,0 +1,15 @@
from __future__ import annotations
from app.modules.agent_api.domain.events.client_event import ClientEventRecord
from app.schemas.client_events import ClientEventType
class StatusMessageFactory:
def create(self, request_id: str, source: str, text: str, payload: dict | None = None) -> ClientEventRecord:
return ClientEventRecord(
request_id=request_id,
type=ClientEventType.STATUS,
source=source,
text=text,
payload=payload or {},
)
@@ -0,0 +1,15 @@
from __future__ import annotations
from app.modules.agent_api.domain.events.client_event import ClientEventRecord
from app.schemas.client_events import ClientEventType
class UserMessageFactory:
def create(self, request_id: str, source: str, text: str, payload: dict | None = None) -> ClientEventRecord:
return ClientEventRecord(
request_id=request_id,
type=ClientEventType.USER,
source=source,
text=text,
payload=payload or {},
)
@@ -0,0 +1,14 @@
from __future__ import annotations
from app.modules.orchestration.processes.v1.process import V1Process
from app.modules.orchestration.processes.v2.process import V2Process
class ProcessRegistry:
def __init__(self, v1_process: V1Process, v2_process: V2Process | None = None) -> None:
self._items = {"v1": v1_process}
if v2_process is not None:
self._items["v2"] = v2_process
def get(self, version: str):
return self._items.get(version)
@@ -0,0 +1,9 @@
from __future__ import annotations
class V1Process:
def __init__(self, steps: list) -> None:
self._steps = steps
def steps(self) -> list:
return list(self._steps)
@@ -0,0 +1,20 @@
from __future__ import annotations
from app.modules.orchestration.context.execution_context import ExecutionContext
class BootstrapStep:
async def run(self, context: ExecutionContext) -> None:
context.trace_logger.log_step(context.request.request_id, "bootstrap", "started")
await context.publisher.publish_status(
context.request.request_id,
"orchestrator",
"Запрос принят и поставлен в обработку.",
)
await context.publisher.publish_status(
context.request.request_id,
"orchestrator",
"Запускаю процесс обработки v1.",
{"process_version": context.request.process_version},
)
context.trace_logger.log_step(context.request.request_id, "bootstrap", "completed")
@@ -0,0 +1,20 @@
from __future__ import annotations
from app.modules.orchestration.context.execution_context import ExecutionContext
class FinalizeStep:
async def run(self, context: ExecutionContext) -> None:
request = context.request
context.trace_logger.log_step(request.request_id, "finalize", "started")
await context.publisher.publish_user(
request.request_id,
"agent",
request.answer or "",
)
await context.publisher.publish_status(
request.request_id,
"orchestrator",
"Обработка запроса завершена.",
)
context.trace_logger.log_step(request.request_id, "finalize", "completed")
@@ -0,0 +1,26 @@
from __future__ import annotations
from app.modules.orchestration.adapters.llm_chat_adapter import LlmChatAdapter
from app.modules.orchestration.context.execution_context import ExecutionContext
class RunLlmStep:
def __init__(self, llm: LlmChatAdapter) -> None:
self._llm = llm
async def run(self, context: ExecutionContext) -> None:
request = context.request
context.trace_logger.log_step(request.request_id, "run_llm", "started")
await context.publisher.publish_status(
request.request_id,
"llm_process",
"Отправляю запрос пользователя в LLM.",
)
answer = await self._llm.generate(request.message, request.request_id)
request.answer = answer
await context.publisher.publish_status(
request.request_id,
"llm_process",
"Ответ от LLM получен.",
)
context.trace_logger.log_step(request.request_id, "run_llm", "completed", {"answer_length": len(answer)})
@@ -0,0 +1,189 @@
# Process V2
`v2` is the current default orchestration process for agent requests.
It is designed as a small stage-based pipeline:
1. accept request
2. route intent
3. run intent-specific workflow
4. publish final user response
The process definition lives in [process.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/orchestration/processes/v2/process.py).
## Step Map
### 1. Bootstrap
File:
[bootstrap_step.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/orchestration/processes/v1/steps/bootstrap_step.py)
Responsibility:
- announce request acceptance
- announce selected process version
- initialize trace logging
SSE source:
- `orchestrator`
### 2. Intent Router
File:
[route_intent_step.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/orchestration/processes/v2/steps/route_intent_step.py)
Responsibility:
- build task runtime context
- enrich request context
- call `IntentRouterV2`
- persist `route_result` into orchestration context
SSE source:
- `intent_router`
Main status messages:
- request is being routed
- final `intent / sub_intent`
### 3. Workflow Execution
Base behavior:
[workflow_step_base.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/orchestration/processes/v2/steps/workflow_step_base.py)
Intent-specific steps:
- [execute_documentation_workflow_step.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/orchestration/processes/v2/steps/execute_documentation_workflow_step.py)
- [execute_openapi_workflow_step.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/orchestration/processes/v2/steps/execute_openapi_workflow_step.py)
- [execute_general_qa_workflow_step.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/orchestration/processes/v2/steps/execute_general_qa_workflow_step.py)
- [execute_fallback_workflow_step.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/orchestration/processes/v2/steps/execute_fallback_workflow_step.py)
Responsibility:
- choose workflow by `route_result.intent`
- run the selected workflow
- expose workflow diagnostics through SSE
- store workflow result in orchestration context
Workflow mapping:
| Intent | Workflow class | Notes |
|---|---|---|
| `DOCUMENTATION_EXPLAIN` | `DocsQaWorkflow` | docs explanation pipeline |
| `OPENAPI_GENERATION` | `OpenApiWorkflow` | OpenAPI generation from docs evidence |
| `GENERAL_QA` | `GeneralQaWorkflow` | general docs-oriented QA |
| any other intent | `FallbackWorkflow` | safety fallback |
Published SSE stages:
- `task_workflow`
- `rag_retrieval`
- `evidence_gate`
- `workflow_result`
### 4. Finalize
File:
[finalize_step.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/orchestration/processes/v1/steps/finalize_step.py)
Responsibility:
- publish final `user` event with answer text
- publish terminal status message
- let facade mark request as `done`
SSE sources:
- `agent`
- `orchestrator`
## Intent Routing
Router adapter:
[intent_router_adapter.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/orchestration/adapters/intent_router_adapter.py)
Underlying implementation:
[router.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/agent/intent_router_v2/router.py)
The router decides:
- `intent`
- `sub_intent`
- retrieval profile
- retrieval layers and constraints
- evidence policy
For docs-oriented requests the main active intents are:
- `DOCUMENTATION_EXPLAIN`
- `OPENAPI_GENERATION`
- `GENERAL_QA`
## Retrieval And Evidence Gate
`v2` does not implement retrieval itself inside orchestration.
Instead it delegates execution to workflow classes that internally use the existing docs pipeline:
- [docs_qa.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/agent/task_runtime/workflows/docs_qa.py)
- [openapi.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/agent/task_runtime/workflows/openapi.py)
- [general_qa.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/agent/task_runtime/workflows/general_qa.py)
These workflows currently rely on:
- `DocsQAPipelineRunner`
- docs retrieval planning
- docs evidence builder
- docs gate logic
Important:
- docs retrieval and gate logic still live in the existing docs runtime layer
- orchestration `v2` only makes those stages explicit at transport/process level
## SSE Contract In V2
Client-visible message categories:
- `status`
- `user`
- `system`
Currently used in `v2`:
- `status` from `orchestrator`
- `status` from `intent_router`
- `status` from `task_workflow`
- `status` from `rag_retrieval`
- `status` from `evidence_gate`
- `status` from `workflow_result`
- `user` from `agent`
Typical sequence:
1. request accepted
2. process `v2` started
3. routing started
4. route selected
5. workflow started
6. retrieval diagnostics
7. evidence gate diagnostics
8. workflow result summary
9. final user answer
10. processing completed
## Trace Logging
Per-request trace files are written by:
[request_trace_logger.py](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/src/app/modules/agent_api/infrastructure/logging/request_trace_logger.py)
Location:
[runtime_traces/agent_requests](/Users/alex/Dev_projects_v2/ai driven app process/v2/agent/runtime_traces/agent_requests)
Each request trace contains:
- request metadata
- user message
- process steps
- emitted events
- final result or error
## Extension Points
Recommended next extensions:
1. add a dedicated workflow step for `GENERATE_DOCS_FROM_CODE`
2. move docs gate diagnostics into strongly typed orchestration payloads
3. split workflow execution into smaller orchestration stages:
- retrieval
- evidence assembly
- gate
- answer generation
4. add session-level trace aggregation in addition to request-level logs
@@ -0,0 +1,9 @@
from __future__ import annotations
class V2Process:
def __init__(self, steps: list) -> None:
self._steps = steps
def steps(self) -> list:
return list(self._steps)
@@ -0,0 +1,7 @@
from __future__ import annotations
from app.modules.orchestration.processes.v2.steps.workflow_step_base import WorkflowStepBase
class ExecuteDocumentationWorkflowStep(WorkflowStepBase):
intent_name = "DOCUMENTATION_EXPLAIN"
@@ -0,0 +1,18 @@
from __future__ import annotations
from app.modules.orchestration.context.execution_context import ExecutionContext
from app.modules.orchestration.processes.v2.steps.workflow_step_base import WorkflowStepBase
class ExecuteFallbackWorkflowStep(WorkflowStepBase):
intent_name = "FALLBACK"
def should_run(self, context: ExecutionContext) -> bool:
route_result = context.route_result
if route_result is None:
return False
return str(route_result.intent or "").upper() not in {
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
}

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