ййй
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+80
-17
@@ -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'}"
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,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,
|
||||
)
|
||||
|
||||
@@ -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 {},
|
||||
},
|
||||
|
||||
@@ -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,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
|
||||
@@ -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)
|
||||
+7
@@ -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
Reference in New Issue
Block a user