Фиксация изменений
This commit is contained in:
Binary file not shown.
BIN
app/modules/chat/__pycache__/direct_service.cpython-312.pyc
Normal file
BIN
app/modules/chat/__pycache__/direct_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/modules/chat/__pycache__/evidence_gate.cpython-312.pyc
Normal file
BIN
app/modules/chat/__pycache__/evidence_gate.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
app/modules/chat/__pycache__/session_resolver.cpython-312.pyc
Normal file
BIN
app/modules/chat/__pycache__/session_resolver.cpython-312.pyc
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
71
app/modules/chat/direct_service.py
Normal file
71
app/modules/chat/direct_service.py
Normal 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,
|
||||
)
|
||||
62
app/modules/chat/evidence_gate.py
Normal file
62
app/modules/chat/evidence_gate.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
36
app/modules/chat/session_resolver.py
Normal file
36
app/modules/chat/session_resolver.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user