Фиксация изменений

This commit is contained in:
2026-03-05 11:03:17 +03:00
parent 1ef0b4d68c
commit 417b8b6f72
261 changed files with 8215 additions and 332 deletions

View File

@@ -1,7 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from uuid import uuid4
from app.modules.chat.repository import ChatRepository
if TYPE_CHECKING:
from app.modules.chat.repository import ChatRepository
@dataclass

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import logging
from uuid import uuid4
from app.modules.agent.llm import AgentLlmService
from app.modules.chat.evidence_gate import CodeExplainEvidenceGate
from app.modules.chat.session_resolver import ChatSessionResolver
from app.modules.chat.task_store import TaskState, TaskStore
from app.modules.rag.explain import CodeExplainRetrieverV2, PromptBudgeter
from app.schemas.chat import ChatMessageRequest, TaskQueuedResponse, TaskResultType, TaskStatus
LOGGER = logging.getLogger(__name__)
class CodeExplainChatService:
def __init__(
self,
retriever: CodeExplainRetrieverV2,
llm: AgentLlmService,
session_resolver: ChatSessionResolver,
task_store: TaskStore,
message_sink,
budgeter: PromptBudgeter | None = None,
evidence_gate: CodeExplainEvidenceGate | None = None,
) -> None:
self._retriever = retriever
self._llm = llm
self._session_resolver = session_resolver
self._task_store = task_store
self._message_sink = message_sink
self._budgeter = budgeter or PromptBudgeter()
self._evidence_gate = evidence_gate or CodeExplainEvidenceGate()
async def handle_message(self, request: ChatMessageRequest) -> TaskQueuedResponse:
dialog_session_id, rag_session_id = self._session_resolver.resolve(request)
task_id = str(uuid4())
task = TaskState(task_id=task_id, status=TaskStatus.RUNNING)
self._task_store.save(task)
self._message_sink(dialog_session_id, "user", request.message, task_id=task_id)
pack = self._retriever.build_pack(
rag_session_id,
request.message,
file_candidates=[item.model_dump(mode="json") for item in request.files],
)
decision = self._evidence_gate.evaluate(pack)
if decision.passed:
prompt_input = self._budgeter.build_prompt_input(request.message, pack)
answer = self._llm.generate(
"code_explain_answer_v2",
prompt_input,
log_context="chat.code_explain.direct",
).strip()
else:
answer = decision.answer
self._message_sink(dialog_session_id, "assistant", answer, task_id=task_id)
task.status = TaskStatus.DONE
task.result_type = TaskResultType.ANSWER
task.answer = answer
self._task_store.save(task)
LOGGER.warning(
"direct code explain response: task_id=%s rag_session_id=%s excerpts=%s missing=%s",
task_id,
rag_session_id,
len(pack.code_excerpts),
pack.missing,
)
return TaskQueuedResponse(
task_id=task_id,
status=TaskStatus.DONE.value,
)

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass, field
from app.modules.rag.explain.models import ExplainPack
@dataclass(slots=True)
class EvidenceGateDecision:
passed: bool
answer: str = ""
diagnostics: dict[str, list[str]] = field(default_factory=dict)
class CodeExplainEvidenceGate:
def __init__(self, min_excerpts: int = 2) -> None:
self._min_excerpts = min_excerpts
def evaluate(self, pack: ExplainPack) -> EvidenceGateDecision:
diagnostics = self._diagnostics(pack)
if len(pack.code_excerpts) >= self._min_excerpts:
return EvidenceGateDecision(passed=True, diagnostics=diagnostics)
return EvidenceGateDecision(
passed=False,
answer=self._build_answer(pack, diagnostics),
diagnostics=diagnostics,
)
def _diagnostics(self, pack: ExplainPack) -> dict[str, list[str]]:
return {
"entrypoints": [item.title for item in pack.selected_entrypoints[:3] if item.title],
"symbols": [item.title for item in pack.seed_symbols[:5] if item.title],
"paths": self._paths(pack),
"missing": list(pack.missing),
}
def _paths(self, pack: ExplainPack) -> list[str]:
values: list[str] = []
for item in pack.selected_entrypoints + pack.seed_symbols:
path = item.source or (item.location.path if item.location else "")
if path and path not in values:
values.append(path)
for excerpt in pack.code_excerpts:
if excerpt.path and excerpt.path not in values:
values.append(excerpt.path)
return values[:6]
def _build_answer(self, pack: ExplainPack, diagnostics: dict[str, list[str]]) -> str:
lines = [
"Недостаточно опоры в коде, чтобы дать объяснение без догадок.",
"",
f"Найдено фрагментов кода: {len(pack.code_excerpts)} из {self._min_excerpts} минимально необходимых.",
]
if diagnostics["paths"]:
lines.append(f"Пути: {', '.join(diagnostics['paths'])}")
if diagnostics["entrypoints"]:
lines.append(f"Entrypoints: {', '.join(diagnostics['entrypoints'])}")
if diagnostics["symbols"]:
lines.append(f"Символы: {', '.join(diagnostics['symbols'])}")
if diagnostics["missing"]:
lines.append(f"Диагностика: {', '.join(diagnostics['missing'])}")
return "\n".join(lines).strip()

View File

@@ -1,13 +1,16 @@
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.repository import ChatRepository
from app.modules.chat.service import ChatOrchestrator
from app.modules.chat.task_store import TaskStore
from app.modules.contracts import AgentRunner
from app.modules.rag_session.session_store import RagSessionStore
from app.modules.shared.event_bus import EventBus
from app.modules.shared.idempotency_store import IdempotencyStore
from app.modules.shared.retry_executor import RetryExecutor
@@ -20,6 +23,11 @@ from app.schemas.chat import (
)
from app.schemas.common import ModuleName
if TYPE_CHECKING:
from app.modules.chat.repository import ChatRepository
from app.modules.contracts import AgentRunner
from app.modules.rag_session.session_store import RagSessionStore
class ChatModule:
def __init__(
@@ -29,12 +37,16 @@ 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.tasks = TaskStore()
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,
@@ -59,11 +71,13 @@ class ChatModule:
rag_session_id=dialog.rag_session_id,
)
@router.post("/api/chat/messages", response_model=TaskQueuedResponse)
@router.post("/api/chat/messages", response_model=TaskQueuedResponse | TaskResultResponse)
async def send_message(
request: ChatMessageRequest,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> TaskQueuedResponse:
) -> 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)

View File

@@ -6,6 +6,7 @@ from app.modules.contracts import AgentRunner
from app.schemas.chat import ChatMessageRequest, TaskResultType, TaskStatus
from app.schemas.common import ErrorPayload, ModuleName
from app.modules.chat.dialog_store import DialogSessionStore
from app.modules.chat.session_resolver import ChatSessionResolver
from app.modules.chat.task_store import TaskState, TaskStore
from app.modules.shared.event_bus import EventBus
from app.modules.shared.idempotency_store import IdempotencyStore
@@ -41,6 +42,7 @@ class ChatOrchestrator:
self._retry = retry
self._rag_session_exists = rag_session_exists
self._message_sink = message_sink
self._session_resolver = ChatSessionResolver(dialogs, rag_session_exists)
async def enqueue_message(
self,
@@ -52,7 +54,7 @@ class ChatOrchestrator:
if existing:
task = self._task_store.get(existing)
if task:
LOGGER.warning(
LOGGER.info(
"enqueue_message reused task by idempotency key: task_id=%s mode=%s",
task.task_id,
request.mode.value,
@@ -63,7 +65,7 @@ class ChatOrchestrator:
if idempotency_key:
self._idempotency.put(idempotency_key, task.task_id)
asyncio.create_task(self._process_task(task.task_id, request))
LOGGER.warning(
LOGGER.info(
"enqueue_message created task: task_id=%s mode=%s",
task.task_id,
request.mode.value,
@@ -135,6 +137,13 @@ class ChatOrchestrator:
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)
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),
)
elif task.result_type == TaskResultType.CHANGESET:
self._message_sink(
dialog_session_id,
@@ -146,6 +155,14 @@ class ChatOrchestrator:
"changeset": [item.model_dump(mode="json") for item in task.changeset],
},
)
LOGGER.warning(
"outgoing chat response: task_id=%s dialog_session_id=%s result_type=%s changeset_items=%s answer=%s",
task_id,
dialog_session_id,
task.result_type.value,
len(task.changeset),
_truncate_for_log(task.answer or ""),
)
self._task_store.save(task)
await self._events.publish(
task_id,
@@ -160,7 +177,7 @@ class ChatOrchestrator:
},
)
await self._publish_progress(task_id, "task.done", "Обработка завершена.", progress=100)
LOGGER.warning(
LOGGER.info(
"_process_task completed: task_id=%s status=%s result_type=%s changeset_items=%s",
task_id,
task.status.value,
@@ -232,7 +249,7 @@ class ChatOrchestrator:
if progress is not None:
payload["progress"] = max(0, min(100, int(progress)))
await self._events.publish(task_id, kind, payload)
LOGGER.warning(
LOGGER.debug(
"_publish_progress emitted: task_id=%s kind=%s stage=%s progress=%s",
task_id,
kind,
@@ -259,35 +276,7 @@ class ChatOrchestrator:
meta={"heartbeat": True},
)
index += 1
LOGGER.warning("_run_heartbeat stopped: task_id=%s ticks=%s", task_id, index)
LOGGER.debug("_run_heartbeat stopped: task_id=%s ticks=%s", task_id, index)
def _resolve_sessions(self, request: ChatMessageRequest) -> tuple[str, str]:
# Legacy compatibility: old session_id/project_id flow.
if request.dialog_session_id and request.rag_session_id:
dialog = self._dialogs.get(request.dialog_session_id)
if not dialog:
raise AppError("dialog_not_found", "Dialog session not found", ModuleName.BACKEND)
if dialog.rag_session_id != request.rag_session_id:
raise AppError("dialog_rag_mismatch", "Dialog session does not belong to rag session", ModuleName.BACKEND)
LOGGER.warning(
"_resolve_sessions resolved by dialog_session_id: dialog_session_id=%s rag_session_id=%s",
request.dialog_session_id,
request.rag_session_id,
)
return request.dialog_session_id, request.rag_session_id
if request.session_id and request.project_id:
if not self._rag_session_exists(request.project_id):
raise AppError("rag_session_not_found", "RAG session not found", ModuleName.RAG)
LOGGER.warning(
"_resolve_sessions resolved by legacy session/project: session_id=%s project_id=%s",
request.session_id,
request.project_id,
)
return request.session_id, request.project_id
raise AppError(
"missing_sessions",
"dialog_session_id and rag_session_id are required",
ModuleName.BACKEND,
)
return self._session_resolver.resolve(request)

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from app.core.exceptions import AppError
from app.schemas.chat import ChatMessageRequest
from app.schemas.common import ModuleName
if TYPE_CHECKING:
from app.modules.chat.dialog_store import DialogSessionStore
class ChatSessionResolver:
def __init__(self, dialogs: DialogSessionStore, rag_session_exists) -> None:
self._dialogs = dialogs
self._rag_session_exists = rag_session_exists
def resolve(self, request: ChatMessageRequest) -> tuple[str, str]:
if request.dialog_session_id and request.rag_session_id:
dialog = self._dialogs.get(request.dialog_session_id)
if not dialog:
raise AppError("dialog_not_found", "Dialog session not found", ModuleName.BACKEND)
if dialog.rag_session_id != request.rag_session_id:
raise AppError("dialog_rag_mismatch", "Dialog session does not belong to rag session", ModuleName.BACKEND)
return request.dialog_session_id, request.rag_session_id
if request.session_id and request.project_id:
if not self._rag_session_exists(request.project_id):
raise AppError("rag_session_not_found", "RAG session not found", ModuleName.RAG)
return request.session_id, request.project_id
raise AppError(
"missing_sessions",
"dialog_session_id and rag_session_id are required",
ModuleName.BACKEND,
)