commit 1e376aff24347a0fd3e372604982103e3499f2f9 Author: zosimovaa Date: Wed Feb 25 14:47:19 2026 +0300 Первый коммит diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..53b3e2c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +*.swp +.git +.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1b157af --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# PostgreSQL +POSTGRES_USER=agent +POSTGRES_PASSWORD=agent +POSTGRES_DB=agent +POSTGRES_PORT=5432 + +# Application DB DSN (used by backend) +DATABASE_URL=postgresql+psycopg://agent:agent@db:5432/agent + +# GigaChat +GIGACHAT_TOKEN= +GIGACHAT_AUTH_URL=https://ngw.devices.sberbank.ru:9443/api/v2/oauth +GIGACHAT_API_URL=https://gigachat.devices.sberbank.ru/api/v1 +GIGACHAT_SCOPE=GIGACHAT_API_PERS +# If your corporate environment uses MITM/self-signed cert chain, set false. +GIGACHAT_SSL_VERIFY=true +GIGACHAT_MODEL=GigaChat +GIGACHAT_EMBEDDING_MODEL=Embeddings + +# Optional +BACKEND_PORT=15000 +AGENT_PROMPTS_DIR= + +RAG_EMBED_BATCH_SIZE=16 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b6eae60 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 15000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "15000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..304b0f4 --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# Agent Backend MVP + +## Run (Local) + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 15000 +``` + +## Run (Docker Compose) + +1. Fill env values: + +```bash +cp .env.example .env +``` + +Set `GIGACHAT_TOKEN` in `.env`. + +2. Start: + +```bash +docker compose up --build +``` + +- Public API: `http://localhost:15000/api/*` +- PostgreSQL + pgvector runs in `db` service on `localhost:5432` + +Stop: + +```bash +docker compose down +``` + +## Public API + +- `POST /api/rag/sessions` +- `POST /api/rag/sessions/{rag_session_id}/changes` +- `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}` +- `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}/events` (SSE progress) +- `POST /api/chat/dialogs` +- `POST /api/chat/messages` +- `GET /api/tasks/{task_id}` +- `GET /api/events?task_id=...` +- `POST /api/index/snapshot` +- `POST /api/index/changes` +- `GET /api/index/jobs/{index_job_id}` +- `GET /api/index/jobs/{index_job_id}/events` (legacy SSE progress) + +RAG indexing SSE events (`/api/rag/sessions/{rag_session_id}/jobs/{index_job_id}/events`): +- `index_status`: `{"index_job_id","status","total_files",...}` +- `index_progress`: `{"index_job_id","current_file_index","total_files","processed_files","current_file_path","current_file_name"}` +- `terminal`: `{"index_job_id","status":"done|error",...}` (final event; stream closes after this event) + +## Session Model + +- `rag_session_id`: identifies indexed project chunks in RAG. +- `dialog_session_id`: identifies one independent dialog context within a `rag_session_id`. +- Multiple dialogs can share one `rag_session_id`. + +Recommended flow: +1. call `POST /api/rag/sessions` after project selection; +2. wait for indexing job completion; +3. call `POST /api/chat/dialogs` with `rag_session_id`; +4. call `POST /api/chat/messages` with `dialog_session_id` + `rag_session_id`. + +`/api/chat/messages` supports explicit routing hint: +- `mode: "auto" | "project_qa" | "project_edits" | "docs_generation" | "qa"` +- Legacy aliases are still accepted: `code_change`, `analytics_review`. + +## Persistence + +Persisted in PostgreSQL: +- `rag_sessions`, `rag_chunks`, `rag_index_jobs` +- `dialog_sessions`, `chat_messages` +- `router_context` (agent routing context per dialog session) +- LangGraph checkpoints via `PostgresSaver` (thread id = `dialog_session_id`) + +Notes: +- RAG vectors are stored in `pgvector` column (`rag_chunks.embedding`). +- Agent context/history is restored after restart for same `dialog_session_id`. + +## Agent Runtime + +- Router + context store (`app/modules/agent/engine/router/*`) +- LangGraph flow execution (`app/modules/agent/engine/graphs/*`) +- Route selection: + - `default/general` -> answer flow + - `project/qa` -> answer flow + - `project/edits` -> conservative changeset flow for non-code file updates + - `docs/generation` -> answer and/or changeset flow +- LLM provider: GigaChat (`chat/completions`) +- Prompts for graph LLM nodes: `app/modules/agent/prompts/*.txt` + - `general_answer.txt` + - `project_answer.txt` + - `project_edits_plan.txt` + - `project_edits_apply.txt` + - `project_edits_self_check.txt` + - `docs_generation.txt` + - `docs_execution_summary.txt` + +## Modules Structure + +- `app/modules/chat/*`: chat API, tasks, session-scoped orchestration, event streaming. +- `app/modules/agent/*`: intent router, LangGraph flows, confluence integration, changeset validation. +- `app/modules/rag/*`: indexing API/jobs and retrieval service. +- `app/modules/shared/*`: cross-module primitives (event bus, retry, idempotency). +- `app/modules/contracts.py`: fixed inter-module contracts (`AgentRunner`, `RagRetriever`, `RagIndexer`). + +## Module Boundaries + +- `chat` depends on contract `AgentRunner`, but not on concrete `agent` internals. +- `agent` depends on contract `RagRetriever`, but not on `rag` indexing internals. +- `rag` exposes public/internal API and service implementation. +- wiring is centralized in `app/modules/application.py`. + +## Internal API (for integration) + +- `POST /internal/rag/index/snapshot` +- `POST /internal/rag/index/changes` +- `GET /internal/rag/index/jobs/{index_job_id}` +- `POST /internal/rag/retrieve` +- `POST /internal/tools/confluence/fetch` + +## Environment + +- `.env.example` contains full list of parameters. +- `.env` is used by Docker Compose for `db` and `backend`. +- `GIGACHAT_TOKEN`: Basic credentials for OAuth exchange (required for LLM/embeddings). +- `DATABASE_URL`: PostgreSQL DSN, default `postgresql+psycopg://agent:agent@db:5432/agent`. +- `GIGACHAT_AUTH_URL`: default `https://ngw.devices.sberbank.ru:9443/api/v2/oauth`. +- `GIGACHAT_API_URL`: default `https://gigachat.devices.sberbank.ru/api/v1`. +- `GIGACHAT_SCOPE`: default `GIGACHAT_API_PERS`. +- `GIGACHAT_SSL_VERIFY`: `true|false`, default `true`. +- `GIGACHAT_MODEL`: chat model name, default `GigaChat`. +- `GIGACHAT_EMBEDDING_MODEL`: embedding model name, default `Embeddings`. +- `AGENT_PROMPTS_DIR`: optional override path for prompt files. + +Troubleshooting: +- If indexing shows `failed_files > 0` and `indexed_files = 0`, check backend logs for TLS/auth errors to GigaChat. +- In corporate environments with custom TLS chain, set `GIGACHAT_SSL_VERIFY=false` (or install proper CA certs in container). diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..1a7ca23 Binary files /dev/null and b/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..009e082 Binary files /dev/null and b/app/__pycache__/main.cpython-312.pyc differ diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/__pycache__/__init__.cpython-312.pyc b/app/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8793877 Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/core/__pycache__/constants.cpython-312.pyc b/app/core/__pycache__/constants.cpython-312.pyc new file mode 100644 index 0000000..2554a46 Binary files /dev/null and b/app/core/__pycache__/constants.cpython-312.pyc differ diff --git a/app/core/__pycache__/error_handlers.cpython-312.pyc b/app/core/__pycache__/error_handlers.cpython-312.pyc new file mode 100644 index 0000000..5544264 Binary files /dev/null and b/app/core/__pycache__/error_handlers.cpython-312.pyc differ diff --git a/app/core/__pycache__/exceptions.cpython-312.pyc b/app/core/__pycache__/exceptions.cpython-312.pyc new file mode 100644 index 0000000..876f785 Binary files /dev/null and b/app/core/__pycache__/exceptions.cpython-312.pyc differ diff --git a/app/core/constants.py b/app/core/constants.py new file mode 100644 index 0000000..76bd87e --- /dev/null +++ b/app/core/constants.py @@ -0,0 +1,5 @@ +from datetime import timedelta + +IDEMPOTENCY_TTL = timedelta(minutes=10) +MAX_RETRIES = 5 +SUPPORTED_SCHEMA_VERSION = "1.0" diff --git a/app/core/error_handlers.py b/app/core/error_handlers.py new file mode 100644 index 0000000..52e8ff3 --- /dev/null +++ b/app/core/error_handlers.py @@ -0,0 +1,37 @@ +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from pydantic import ValidationError + +from app.core.exceptions import AppError +from app.schemas.common import ModuleName + + +def register_error_handlers(app: FastAPI) -> None: + @app.exception_handler(AppError) + async def app_error_handler(_: Request, exc: AppError) -> JSONResponse: + return JSONResponse( + status_code=400, + content={"code": exc.code, "desc": exc.desc, "module": exc.module.value}, + ) + + @app.exception_handler(ValidationError) + async def validation_error_handler(_: Request, exc: ValidationError) -> JSONResponse: + return JSONResponse( + status_code=422, + content={ + "code": "validation_error", + "desc": str(exc), + "module": ModuleName.BACKEND.value, + }, + ) + + @app.exception_handler(Exception) + async def generic_error_handler(_: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=500, + content={ + "code": "internal_error", + "desc": str(exc), + "module": ModuleName.BACKEND.value, + }, + ) diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..63dd80b --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,9 @@ +from app.schemas.common import ModuleName + + +class AppError(Exception): + def __init__(self, code: str, desc: str, module: ModuleName) -> None: + super().__init__(desc) + self.code = code + self.desc = desc + self.module = module diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f8619d6 --- /dev/null +++ b/app/main.py @@ -0,0 +1,38 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.error_handlers import register_error_handlers +from app.modules.application import ModularApplication + + +def create_app() -> FastAPI: + app = FastAPI(title="Agent Backend MVP", version="0.1.0") + modules = ModularApplication() + app.state.modules = modules + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(modules.chat.public_router()) + app.include_router(modules.rag.public_router()) + app.include_router(modules.rag.internal_router()) + app.include_router(modules.agent.internal_router()) + + register_error_handlers(app) + + @app.on_event("startup") + async def startup() -> None: + modules.startup() + + @app.get("/health") + async def health() -> dict: + return {"status": "ok"} + + return app + + +app = create_app() diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/__pycache__/__init__.cpython-312.pyc b/app/modules/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..890a2b8 Binary files /dev/null and b/app/modules/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/__pycache__/application.cpython-312.pyc b/app/modules/__pycache__/application.cpython-312.pyc new file mode 100644 index 0000000..1f2b3b0 Binary files /dev/null and b/app/modules/__pycache__/application.cpython-312.pyc differ diff --git a/app/modules/__pycache__/contracts.cpython-312.pyc b/app/modules/__pycache__/contracts.cpython-312.pyc new file mode 100644 index 0000000..a7a5ed4 Binary files /dev/null and b/app/modules/__pycache__/contracts.cpython-312.pyc differ diff --git a/app/modules/agent/__init__.py b/app/modules/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/agent/__pycache__/__init__.cpython-312.pyc b/app/modules/agent/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..42784e9 Binary files /dev/null and b/app/modules/agent/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/agent/__pycache__/changeset_validator.cpython-312.pyc b/app/modules/agent/__pycache__/changeset_validator.cpython-312.pyc new file mode 100644 index 0000000..d8ca59d Binary files /dev/null and b/app/modules/agent/__pycache__/changeset_validator.cpython-312.pyc differ diff --git a/app/modules/agent/__pycache__/confluence_service.cpython-312.pyc b/app/modules/agent/__pycache__/confluence_service.cpython-312.pyc new file mode 100644 index 0000000..2bc7fa7 Binary files /dev/null and b/app/modules/agent/__pycache__/confluence_service.cpython-312.pyc differ diff --git a/app/modules/agent/__pycache__/module.cpython-312.pyc b/app/modules/agent/__pycache__/module.cpython-312.pyc new file mode 100644 index 0000000..ce87d5a Binary files /dev/null and b/app/modules/agent/__pycache__/module.cpython-312.pyc differ diff --git a/app/modules/agent/__pycache__/prompt_loader.cpython-312.pyc b/app/modules/agent/__pycache__/prompt_loader.cpython-312.pyc new file mode 100644 index 0000000..f4e320c Binary files /dev/null and b/app/modules/agent/__pycache__/prompt_loader.cpython-312.pyc differ diff --git a/app/modules/agent/__pycache__/repository.cpython-312.pyc b/app/modules/agent/__pycache__/repository.cpython-312.pyc new file mode 100644 index 0000000..2cdc549 Binary files /dev/null and b/app/modules/agent/__pycache__/repository.cpython-312.pyc differ diff --git a/app/modules/agent/__pycache__/service.cpython-312.pyc b/app/modules/agent/__pycache__/service.cpython-312.pyc new file mode 100644 index 0000000..52daeee Binary files /dev/null and b/app/modules/agent/__pycache__/service.cpython-312.pyc differ diff --git a/app/modules/agent/changeset_validator.py b/app/modules/agent/changeset_validator.py new file mode 100644 index 0000000..f764fea --- /dev/null +++ b/app/modules/agent/changeset_validator.py @@ -0,0 +1,20 @@ +from app.core.constants import SUPPORTED_SCHEMA_VERSION +from app.core.exceptions import AppError +from app.schemas.changeset import ChangeItem, ChangeSetPayload +from app.schemas.common import ModuleName + + +class ChangeSetValidator: + def validate(self, task_id: str, changeset: list[ChangeItem]) -> list[ChangeItem]: + payload = ChangeSetPayload( + schema_version=SUPPORTED_SCHEMA_VERSION, + task_id=task_id, + changeset=changeset, + ) + if payload.schema_version != SUPPORTED_SCHEMA_VERSION: + raise AppError( + "unsupported_schema", + f"Unsupported schema version: {payload.schema_version}", + ModuleName.AGENT, + ) + return payload.changeset diff --git a/app/modules/agent/confluence_service.py b/app/modules/agent/confluence_service.py new file mode 100644 index 0000000..0854daf --- /dev/null +++ b/app/modules/agent/confluence_service.py @@ -0,0 +1,20 @@ +from datetime import datetime, timezone +from urllib.parse import urlparse +from uuid import uuid4 + +from app.core.exceptions import AppError +from app.schemas.common import ModuleName + + +class ConfluenceService: + async def fetch_page(self, url: str) -> dict: + parsed = urlparse(url) + if not parsed.scheme.startswith("http"): + raise AppError("invalid_url", "Invalid Confluence URL", ModuleName.CONFLUENCE) + return { + "page_id": str(uuid4()), + "title": "Confluence page", + "content_markdown": f"Fetched content from {url}", + "version": 1, + "fetched_at": datetime.now(timezone.utc).isoformat(), + } diff --git a/app/modules/agent/engine/__init__.py b/app/modules/agent/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/agent/engine/__pycache__/__init__.cpython-312.pyc b/app/modules/agent/engine/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7f6fbb0 Binary files /dev/null and b/app/modules/agent/engine/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__init__.py b/app/modules/agent/engine/graphs/__init__.py new file mode 100644 index 0000000..caf5035 --- /dev/null +++ b/app/modules/agent/engine/graphs/__init__.py @@ -0,0 +1,11 @@ +from app.modules.agent.engine.graphs.base_graph import BaseGraphFactory +from app.modules.agent.engine.graphs.docs_graph import DocsGraphFactory +from app.modules.agent.engine.graphs.project_edits_graph import ProjectEditsGraphFactory +from app.modules.agent.engine.graphs.project_qa_graph import ProjectQaGraphFactory + +__all__ = [ + "BaseGraphFactory", + "DocsGraphFactory", + "ProjectEditsGraphFactory", + "ProjectQaGraphFactory", +] diff --git a/app/modules/agent/engine/graphs/__pycache__/__init__.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a7cc9ef Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/analytics_graph.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/analytics_graph.cpython-312.pyc new file mode 100644 index 0000000..5195dff Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/analytics_graph.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/base_graph.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/base_graph.cpython-312.pyc new file mode 100644 index 0000000..5d55c82 Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/base_graph.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/code_change_graph.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/code_change_graph.cpython-312.pyc new file mode 100644 index 0000000..ef9e0d9 Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/code_change_graph.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/docs_examples_loader.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/docs_examples_loader.cpython-312.pyc new file mode 100644 index 0000000..afc787c Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/docs_examples_loader.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/docs_graph.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/docs_graph.cpython-312.pyc new file mode 100644 index 0000000..94711bf Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/docs_graph.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/docs_graph_logic.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/docs_graph_logic.cpython-312.pyc new file mode 100644 index 0000000..ce3f785 Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/docs_graph_logic.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/file_targeting.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/file_targeting.cpython-312.pyc new file mode 100644 index 0000000..6fd5580 Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/file_targeting.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/progress.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/progress.cpython-312.pyc new file mode 100644 index 0000000..715180f Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/progress.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/progress_registry.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/progress_registry.cpython-312.pyc new file mode 100644 index 0000000..0417c49 Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/progress_registry.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/project_edits_graph.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/project_edits_graph.cpython-312.pyc new file mode 100644 index 0000000..dcd3bcc Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/project_edits_graph.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/project_edits_logic.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/project_edits_logic.cpython-312.pyc new file mode 100644 index 0000000..91ab4d3 Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/project_edits_logic.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/project_qa_graph.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/project_qa_graph.cpython-312.pyc new file mode 100644 index 0000000..8d231ff Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/project_qa_graph.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/__pycache__/state.cpython-312.pyc b/app/modules/agent/engine/graphs/__pycache__/state.cpython-312.pyc new file mode 100644 index 0000000..0332059 Binary files /dev/null and b/app/modules/agent/engine/graphs/__pycache__/state.cpython-312.pyc differ diff --git a/app/modules/agent/engine/graphs/base_graph.py b/app/modules/agent/engine/graphs/base_graph.py new file mode 100644 index 0000000..28b4c81 --- /dev/null +++ b/app/modules/agent/engine/graphs/base_graph.py @@ -0,0 +1,58 @@ +from langgraph.graph import END, START, StateGraph + +from app.modules.agent.engine.graphs.progress import emit_progress_sync +from app.modules.agent.llm import AgentLlmService +from app.modules.agent.engine.graphs.state import AgentGraphState + + +class BaseGraphFactory: + def __init__(self, llm: AgentLlmService) -> None: + self._llm = llm + + def build(self, checkpointer=None): + graph = StateGraph(AgentGraphState) + graph.add_node("context", self._context_node) + graph.add_node("answer", self._answer_node) + graph.add_edge(START, "context") + graph.add_edge("context", "answer") + graph.add_edge("answer", END) + return graph.compile(checkpointer=checkpointer) + + def _context_node(self, state: AgentGraphState) -> dict: + emit_progress_sync( + state, + stage="graph.default.context", + message="Готовлю контекст ответа по данным запроса.", + ) + rag = state.get("rag_context", "") + conf = state.get("confluence_context", "") + emit_progress_sync( + state, + stage="graph.default.context.done", + message="Контекст собран, перехожу к формированию ответа.", + ) + return {"rag_context": rag, "confluence_context": conf} + + def _answer_node(self, state: AgentGraphState) -> dict: + emit_progress_sync( + state, + stage="graph.default.answer", + message="Формирую текст ответа для пользователя.", + ) + msg = state.get("message", "") + rag = state.get("rag_context", "") + conf = state.get("confluence_context", "") + user_input = "\n\n".join( + [ + f"User request:\n{msg}", + f"RAG context:\n{rag}", + f"Confluence context:\n{conf}", + ] + ) + answer = self._llm.generate("general_answer", user_input) + emit_progress_sync( + state, + stage="graph.default.answer.done", + message="Черновик ответа подготовлен.", + ) + return {"answer": answer} diff --git a/app/modules/agent/engine/graphs/docs_examples_loader.py b/app/modules/agent/engine/graphs/docs_examples_loader.py new file mode 100644 index 0000000..ddc139a --- /dev/null +++ b/app/modules/agent/engine/graphs/docs_examples_loader.py @@ -0,0 +1,26 @@ +from pathlib import Path +import os + + +class DocsExamplesLoader: + def __init__(self, prompts_dir: Path | None = None) -> None: + base = prompts_dir or Path(__file__).resolve().parents[2] / "prompts" + env_override = os.getenv("AGENT_PROMPTS_DIR", "").strip() + root = Path(env_override) if env_override else base + self._examples_dir = root / "docs_examples" + + def load_bundle(self, *, max_files: int = 6, max_chars_per_file: int = 1800) -> str: + if not self._examples_dir.is_dir(): + return "" + files = sorted( + [p for p in self._examples_dir.iterdir() if p.is_file() and p.suffix.lower() in {".md", ".txt"}], + key=lambda p: p.name.lower(), + )[:max_files] + chunks: list[str] = [] + for path in files: + content = path.read_text(encoding="utf-8", errors="ignore").strip() + if not content: + continue + excerpt = content[:max_chars_per_file].strip() + chunks.append(f"### Example: {path.name}\n{excerpt}") + return "\n\n".join(chunks).strip() diff --git a/app/modules/agent/engine/graphs/docs_graph.py b/app/modules/agent/engine/graphs/docs_graph.py new file mode 100644 index 0000000..298ddb7 --- /dev/null +++ b/app/modules/agent/engine/graphs/docs_graph.py @@ -0,0 +1,128 @@ +from langgraph.graph import END, START, StateGraph +import logging + +from app.modules.agent.engine.graphs.file_targeting import FileTargeting +from app.modules.agent.engine.graphs.docs_graph_logic import DocsContentComposer, DocsContextAnalyzer +from app.modules.agent.engine.graphs.progress import emit_progress_sync +from app.modules.agent.engine.graphs.state import AgentGraphState +from app.modules.agent.llm import AgentLlmService + +LOGGER = logging.getLogger(__name__) + + +class DocsGraphFactory: + _max_validation_attempts = 2 + + def __init__(self, llm: AgentLlmService) -> None: + self._targeting = FileTargeting() + self._analyzer = DocsContextAnalyzer(llm, self._targeting) + self._composer = DocsContentComposer(llm, self._targeting) + + def build(self, checkpointer=None): + graph = StateGraph(AgentGraphState) + graph.add_node("collect_code_context", self._collect_code_context) + graph.add_node("detect_existing_docs", self._detect_existing_docs) + graph.add_node("decide_strategy", self._decide_strategy) + graph.add_node("load_rules_and_examples", self._load_rules_and_examples) + graph.add_node("plan_incremental_changes", self._plan_incremental_changes) + graph.add_node("plan_new_document", self._plan_new_document) + graph.add_node("generate_doc_content", self._generate_doc_content) + graph.add_node("self_check", self._self_check) + graph.add_node("build_changeset", self._build_changeset) + graph.add_node("summarize_result", self._summarize_result) + + graph.add_edge(START, "collect_code_context") + graph.add_edge("collect_code_context", "detect_existing_docs") + graph.add_edge("detect_existing_docs", "decide_strategy") + graph.add_edge("decide_strategy", "load_rules_and_examples") + graph.add_conditional_edges( + "load_rules_and_examples", + self._route_after_rules_loading, + { + "incremental": "plan_incremental_changes", + "from_scratch": "plan_new_document", + }, + ) + graph.add_edge("plan_incremental_changes", "generate_doc_content") + graph.add_edge("plan_new_document", "generate_doc_content") + graph.add_edge("generate_doc_content", "self_check") + graph.add_conditional_edges( + "self_check", + self._route_after_self_check, + {"retry": "generate_doc_content", "ready": "build_changeset"}, + ) + graph.add_edge("build_changeset", "summarize_result") + graph.add_edge("summarize_result", END) + return graph.compile(checkpointer=checkpointer) + + def _collect_code_context(self, state: AgentGraphState) -> dict: + return self._run_node(state, "collect_code_context", "Собираю контекст кода и файлов.", self._analyzer.collect_code_context) + + def _detect_existing_docs(self, state: AgentGraphState) -> dict: + return self._run_node( + state, + "detect_existing_docs", + "Определяю, есть ли существующая документация проекта.", + self._analyzer.detect_existing_docs, + ) + + def _decide_strategy(self, state: AgentGraphState) -> dict: + return self._run_node(state, "decide_strategy", "Выбираю стратегию: инкремент или генерация с нуля.", self._analyzer.decide_strategy) + + def _load_rules_and_examples(self, state: AgentGraphState) -> dict: + return self._run_node( + state, + "load_rules_and_examples", + "Загружаю правила и примеры формата документации.", + self._composer.load_rules_and_examples, + ) + + def _plan_incremental_changes(self, state: AgentGraphState) -> dict: + return self._run_node( + state, + "plan_incremental_changes", + "Планирую точечные изменения в существующей документации.", + lambda st: self._composer.plan_incremental_changes(st, self._analyzer), + ) + + def _plan_new_document(self, state: AgentGraphState) -> dict: + return self._run_node(state, "plan_new_document", "Проектирую структуру новой документации.", self._composer.plan_new_document) + + def _generate_doc_content(self, state: AgentGraphState) -> dict: + return self._run_node(state, "generate_doc_content", "Генерирую содержимое документации.", self._composer.generate_doc_content) + + def _self_check(self, state: AgentGraphState) -> dict: + return self._run_node(state, "self_check", "Проверяю соответствие результата правилам.", self._composer.self_check) + + def _build_changeset(self, state: AgentGraphState) -> dict: + return self._run_node(state, "build_changeset", "Формирую итоговый набор изменений файлов.", self._composer.build_changeset) + + def _summarize_result(self, state: AgentGraphState) -> dict: + return self._run_node( + state, + "summarize_result", + "Формирую краткий обзор выполненных действий и измененных файлов.", + self._composer.build_execution_summary, + ) + + def _route_after_rules_loading(self, state: AgentGraphState) -> str: + if state.get("docs_strategy") == "incremental_update": + return "incremental" + return "from_scratch" + + def _route_after_self_check(self, state: AgentGraphState) -> str: + if state.get("validation_passed"): + return "ready" + attempts = int(state.get("validation_attempts", 0) or 0) + return "ready" if attempts >= self._max_validation_attempts else "retry" + + def _run_node(self, state: AgentGraphState, node_name: str, message: str, fn): + emit_progress_sync(state, stage=f"graph.docs.{node_name}", message=message) + try: + result = fn(state) + emit_progress_sync(state, stage=f"graph.docs.{node_name}.done", message=f"Шаг '{node_name}' завершен.") + LOGGER.warning("docs graph node completed: node=%s keys=%s", node_name, sorted(result.keys())) + return result + except Exception: + LOGGER.exception("docs graph node failed: node=%s", node_name) + raise diff --git a/app/modules/agent/engine/graphs/docs_graph_logic.py b/app/modules/agent/engine/graphs/docs_graph_logic.py new file mode 100644 index 0000000..c1d310c --- /dev/null +++ b/app/modules/agent/engine/graphs/docs_graph_logic.py @@ -0,0 +1,519 @@ +import json +from difflib import SequenceMatcher + +from app.modules.agent.engine.graphs.docs_examples_loader import DocsExamplesLoader +from app.modules.agent.engine.graphs.file_targeting import FileTargeting +from app.modules.agent.engine.graphs.state import AgentGraphState +from app.modules.agent.llm import AgentLlmService +from app.schemas.changeset import ChangeItem +import logging + +LOGGER = logging.getLogger(__name__) + + +class DocsContextAnalyzer: + def __init__(self, llm: AgentLlmService, targeting: FileTargeting) -> None: + self._llm = llm + self._targeting = targeting + + def collect_code_context(self, state: AgentGraphState) -> dict: + message = state.get("message", "") + files_map = state.get("files_map", {}) or {} + requested_path = self._targeting.extract_target_path(message) + target_file = self._targeting.lookup_file(files_map, requested_path) if requested_path else None + docs_candidates = self._collect_doc_candidates(files_map) + target_path = str((target_file or {}).get("path") or (requested_path or "")).strip() or "" + return { + "docs_candidates": docs_candidates, + "target_path": target_path, + "target_file_content": str((target_file or {}).get("content", "")), + "target_file_hash": str((target_file or {}).get("content_hash", "")), + "validation_attempts": 0, + } + + def detect_existing_docs(self, state: AgentGraphState) -> dict: + docs_candidates = state.get("docs_candidates", []) or [] + if not docs_candidates: + return { + "existing_docs_detected": False, + "existing_docs_summary": "No documentation files detected in current project context.", + } + + snippets = "\n\n".join( + [ + f"Path: {item.get('path', '')}\nSnippet:\n{self._shorten(item.get('content', ''), 500)}" + for item in docs_candidates[:8] + ] + ) + user_input = "\n\n".join( + [ + f"User request:\n{state.get('message', '')}", + f"Requested target path:\n{state.get('target_path', '') or '(not specified)'}", + f"Detected documentation candidates:\n{snippets}", + ] + ) + raw = self._llm.generate("docs_detect", user_input) + exists = self.parse_bool_marker(raw, "exists", default=True) + summary = self.parse_text_marker(raw, "summary", default="Documentation files detected.") + return {"existing_docs_detected": exists, "existing_docs_summary": summary} + + def decide_strategy(self, state: AgentGraphState) -> dict: + message = (state.get("message", "") or "").lower() + if any(token in message for token in ("с нуля", "from scratch", "new documentation", "создай документацию")): + return {"docs_strategy": "from_scratch"} + if any(token in message for token in ("дополни", "обнови документацию", "extend docs", "update docs")): + return {"docs_strategy": "incremental_update"} + + user_input = "\n\n".join( + [ + f"User request:\n{state.get('message', '')}", + f"Existing docs detected:\n{state.get('existing_docs_detected', False)}", + f"Existing docs summary:\n{state.get('existing_docs_summary', '')}", + ] + ) + raw = self._llm.generate("docs_strategy", user_input) + strategy = self.parse_text_marker(raw, "strategy", default="").lower() + if strategy not in {"incremental_update", "from_scratch"}: + strategy = "incremental_update" if state.get("existing_docs_detected", False) else "from_scratch" + return {"docs_strategy": strategy} + + def resolve_target_for_incremental(self, state: AgentGraphState) -> tuple[str, dict | None]: + files_map = state.get("files_map", {}) or {} + preferred_path = state.get("target_path", "") + preferred = self._targeting.lookup_file(files_map, preferred_path) + if preferred: + return str(preferred.get("path") or preferred_path), preferred + candidates = state.get("docs_candidates", []) or [] + if candidates: + first_path = str(candidates[0].get("path", "")) + resolved = self._targeting.lookup_file(files_map, first_path) or candidates[0] + return first_path, resolved + fallback = preferred_path.strip() or "docs/AGENT_DRAFT.md" + return fallback, None + + def _collect_doc_candidates(self, files_map: dict[str, dict]) -> list[dict]: + candidates: list[dict] = [] + for raw_path, payload in files_map.items(): + path = str(raw_path or "").replace("\\", "/").strip() + if not path: + continue + low = path.lower() + is_doc = low.startswith("docs/") or low.endswith(".md") or low.endswith(".rst") or "/readme" in low or low.startswith("readme") + if not is_doc: + continue + candidates.append( + { + "path": str(payload.get("path") or path), + "content": str(payload.get("content", "")), + "content_hash": str(payload.get("content_hash", "")), + } + ) + candidates.sort(key=lambda item: (0 if str(item.get("path", "")).lower().startswith("docs/") else 1, str(item.get("path", "")).lower())) + return candidates + + def _shorten(self, text: str, max_chars: int) -> str: + value = (text or "").strip() + if len(value) <= max_chars: + return value + return value[:max_chars].rstrip() + "\n...[truncated]" + + @staticmethod + def parse_bool_marker(text: str, marker: str, *, default: bool) -> bool: + value = DocsContextAnalyzer.parse_text_marker(text, marker, default="") + if not value: + return default + token = value.split()[0].strip().lower() + if token in {"yes", "true", "1", "да"}: + return True + if token in {"no", "false", "0", "нет"}: + return False + return default + + @staticmethod + def parse_text_marker(text: str, marker: str, *, default: str) -> str: + low_marker = f"{marker.lower()}:" + for line in (text or "").splitlines(): + raw = line.strip() + if raw.lower().startswith(low_marker): + return raw.split(":", 1)[1].strip() + return default + + +class DocsBundleFormatter: + def shorten(self, text: str, max_chars: int) -> str: + value = (text or "").strip() + if len(value) <= max_chars: + return value + return value[:max_chars].rstrip() + "\n...[truncated]" + + def normalize_file_output(self, text: str) -> str: + value = (text or "").strip() + if value.startswith("```") and value.endswith("```"): + lines = value.splitlines() + if len(lines) >= 3: + return "\n".join(lines[1:-1]).strip() + return value + + def parse_docs_bundle(self, raw_text: str) -> list[dict]: + text = (raw_text or "").strip() + if not text: + return [] + + candidate = self.normalize_file_output(text) + parsed = self._parse_json_candidate(candidate) + if parsed is None: + start = candidate.find("{") + end = candidate.rfind("}") + if start != -1 and end > start: + parsed = self._parse_json_candidate(candidate[start : end + 1]) + if parsed is None: + return [] + + files: list[dict] + if isinstance(parsed, dict): + raw_files = parsed.get("files") + files = raw_files if isinstance(raw_files, list) else [] + elif isinstance(parsed, list): + files = parsed + else: + files = [] + + out: list[dict] = [] + seen: set[str] = set() + for item in files: + if not isinstance(item, dict): + continue + path = str(item.get("path", "")).replace("\\", "/").strip() + content = str(item.get("content", "")) + if not path or not content.strip(): + continue + if path in seen: + continue + seen.add(path) + out.append( + { + "path": path, + "content": content, + "reason": str(item.get("reason", "")).strip(), + } + ) + return out + + def bundle_has_required_structure(self, bundle: list[dict]) -> bool: + if not bundle: + return False + has_api = any(str(item.get("path", "")).replace("\\", "/").startswith("docs/api/") for item in bundle) + has_logic = any(str(item.get("path", "")).replace("\\", "/").startswith("docs/logic/") for item in bundle) + return has_api and has_logic + + def similarity(self, original: str, updated: str) -> float: + return SequenceMatcher(None, original or "", updated or "").ratio() + + def line_change_ratio(self, original: str, updated: str) -> float: + orig_lines = (original or "").splitlines() + new_lines = (updated or "").splitlines() + if not orig_lines and not new_lines: + return 0.0 + matcher = SequenceMatcher(None, orig_lines, new_lines) + changed = 0 + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + continue + changed += max(i2 - i1, j2 - j1) + total = max(len(orig_lines), len(new_lines), 1) + return changed / total + + def added_headings(self, original: str, updated: str) -> int: + old_heads = {line.strip() for line in (original or "").splitlines() if line.strip().startswith("#")} + new_heads = {line.strip() for line in (updated or "").splitlines() if line.strip().startswith("#")} + return len(new_heads - old_heads) + + def collapse_whitespace(self, text: str) -> str: + return " ".join((text or "").split()) + + def _parse_json_candidate(self, text: str): + try: + return json.loads(text) + except Exception: + return None + + +class DocsContentComposer: + def __init__(self, llm: AgentLlmService, targeting: FileTargeting) -> None: + self._llm = llm + self._targeting = targeting + self._examples = DocsExamplesLoader() + self._bundle = DocsBundleFormatter() + + def load_rules_and_examples(self, _state: AgentGraphState) -> dict: + return {"rules_bundle": self._examples.load_bundle()} + + def plan_incremental_changes(self, state: AgentGraphState, analyzer: DocsContextAnalyzer) -> dict: + target_path, target = analyzer.resolve_target_for_incremental(state) + user_input = "\n\n".join( + [ + "Strategy: incremental_update", + f"User request:\n{state.get('message', '')}", + f"Target path:\n{target_path}", + f"Current target content:\n{self._bundle.shorten((target or {}).get('content', ''), 3000)}", + f"RAG context:\n{self._bundle.shorten(state.get('rag_context', ''), 6000)}", + f"Examples bundle:\n{state.get('rules_bundle', '')}", + ] + ) + plan = self._llm.generate("docs_plan_sections", user_input) + return { + "doc_plan": plan, + "target_path": target_path, + "target_file_content": str((target or {}).get("content", "")), + "target_file_hash": str((target or {}).get("content_hash", "")), + } + + def plan_new_document(self, state: AgentGraphState) -> dict: + target_path = state.get("target_path", "").strip() or "docs/AGENT_DRAFT.md" + user_input = "\n\n".join( + [ + "Strategy: from_scratch", + f"User request:\n{state.get('message', '')}", + f"Target path:\n{target_path}", + f"RAG context:\n{self._bundle.shorten(state.get('rag_context', ''), 6000)}", + f"Examples bundle:\n{state.get('rules_bundle', '')}", + ] + ) + plan = self._llm.generate("docs_plan_sections", user_input) + return {"doc_plan": plan, "target_path": target_path, "target_file_content": "", "target_file_hash": ""} + + def generate_doc_content(self, state: AgentGraphState) -> dict: + user_input = "\n\n".join( + [ + f"Strategy:\n{state.get('docs_strategy', 'from_scratch')}", + f"User request:\n{state.get('message', '')}", + f"Target path:\n{state.get('target_path', '')}", + f"Document plan:\n{state.get('doc_plan', '')}", + f"Current target content:\n{self._bundle.shorten(state.get('target_file_content', ''), 3500)}", + f"RAG context:\n{self._bundle.shorten(state.get('rag_context', ''), 7000)}", + f"Examples bundle:\n{state.get('rules_bundle', '')}", + ] + ) + raw = self._llm.generate("docs_generation", user_input) + bundle = self._bundle.parse_docs_bundle(raw) + if bundle: + first_content = str(bundle[0].get("content", "")).strip() + return {"generated_docs_bundle": bundle, "generated_doc": first_content} + content = self._bundle.normalize_file_output(raw) + return {"generated_docs_bundle": [], "generated_doc": content} + + def self_check(self, state: AgentGraphState) -> dict: + attempts = int(state.get("validation_attempts", 0) or 0) + 1 + bundle = state.get("generated_docs_bundle", []) or [] + generated = state.get("generated_doc", "") + if not generated.strip() and not bundle: + return { + "validation_attempts": attempts, + "validation_passed": False, + "validation_feedback": "Generated document is empty.", + } + strategy = state.get("docs_strategy", "from_scratch") + if strategy == "from_scratch" and not self._bundle.bundle_has_required_structure(bundle): + return { + "validation_attempts": attempts, + "validation_passed": False, + "validation_feedback": "Bundle must include both docs/api and docs/logic for from_scratch strategy.", + } + if strategy == "incremental_update": + if bundle and len(bundle) > 1 and not self._is_broad_rewrite_request(str(state.get("message", ""))): + return { + "validation_attempts": attempts, + "validation_passed": False, + "validation_feedback": "Incremental update should not touch multiple files without explicit broad rewrite request.", + } + original = str(state.get("target_file_content", "")) + broad = self._is_broad_rewrite_request(str(state.get("message", ""))) + if original and generated: + if self._bundle.collapse_whitespace(original) == self._bundle.collapse_whitespace(generated): + return { + "validation_attempts": attempts, + "validation_passed": False, + "validation_feedback": "Only formatting/whitespace changes detected.", + } + similarity = self._bundle.similarity(original, generated) + change_ratio = self._bundle.line_change_ratio(original, generated) + added_headings = self._bundle.added_headings(original, generated) + min_similarity = 0.75 if broad else 0.9 + max_change_ratio = 0.7 if broad else 0.35 + if similarity < min_similarity: + return { + "validation_attempts": attempts, + "validation_passed": False, + "validation_feedback": f"Incremental update is too broad (similarity={similarity:.2f}).", + } + if change_ratio > max_change_ratio: + return { + "validation_attempts": attempts, + "validation_passed": False, + "validation_feedback": f"Incremental update changes too many lines (change_ratio={change_ratio:.2f}).", + } + if not broad and added_headings > 0: + return { + "validation_attempts": attempts, + "validation_passed": False, + "validation_feedback": "New section headings were added outside requested scope.", + } + + bundle_text = "\n".join([f"- {item.get('path', '')}" for item in bundle[:30]]) + user_input = "\n\n".join( + [ + f"Strategy:\n{strategy}", + f"User request:\n{state.get('message', '')}", + f"Document plan:\n{state.get('doc_plan', '')}", + f"Generated file paths:\n{bundle_text or '(single-file mode)'}", + f"Generated document:\n{generated}", + ] + ) + raw = self._llm.generate("docs_self_check", user_input) + passed = DocsContextAnalyzer.parse_bool_marker(raw, "pass", default=False) + feedback = DocsContextAnalyzer.parse_text_marker(raw, "feedback", default="No validation feedback provided.") + return {"validation_attempts": attempts, "validation_passed": passed, "validation_feedback": feedback} + + def build_changeset(self, state: AgentGraphState) -> dict: + files_map = state.get("files_map", {}) or {} + bundle = state.get("generated_docs_bundle", []) or [] + strategy = state.get("docs_strategy", "from_scratch") + if strategy == "from_scratch" and not self._bundle.bundle_has_required_structure(bundle): + LOGGER.warning( + "build_changeset fallback bundle used: strategy=%s bundle_items=%s", + strategy, + len(bundle), + ) + bundle = self._build_fallback_bundle_from_text(state.get("generated_doc", "")) + if bundle: + changes: list[ChangeItem] = [] + for item in bundle: + path = str(item.get("path", "")).replace("\\", "/").strip() + content = str(item.get("content", "")) + if not path or not content.strip(): + continue + target = self._targeting.lookup_file(files_map, path) + reason = str(item.get("reason", "")).strip() or f"Documentation {strategy}: generated file from structured bundle." + if target and target.get("content_hash"): + changes.append( + ChangeItem( + op="update", + path=str(target.get("path") or path), + base_hash=str(target.get("content_hash", "")), + proposed_content=content, + reason=reason, + ) + ) + else: + changes.append( + ChangeItem( + op="create", + path=path, + proposed_content=content, + reason=reason, + ) + ) + if changes: + return {"changeset": changes} + + target_path = (state.get("target_path", "") or "").strip() or "docs/AGENT_DRAFT.md" + target = self._targeting.lookup_file(files_map, target_path) + content = state.get("generated_doc", "") + if target and target.get("content_hash"): + change = ChangeItem( + op="update", + path=str(target.get("path") or target_path), + base_hash=str(target.get("content_hash", "")), + proposed_content=content, + reason=f"Documentation {strategy}: update existing document increment.", + ) + else: + change = ChangeItem( + op="create", + path=target_path, + proposed_content=content, + reason=f"Documentation {strategy}: create document from current project context.", + ) + return {"changeset": [change]} + + def build_execution_summary(self, state: AgentGraphState) -> dict: + changeset = state.get("changeset", []) or [] + if not changeset: + return {"answer": "Документация не была изменена: итоговый changeset пуст."} + + file_lines = self._format_changed_files(changeset) + user_input = "\n\n".join( + [ + f"User request:\n{state.get('message', '')}", + f"Documentation strategy:\n{state.get('docs_strategy', 'from_scratch')}", + f"Document plan:\n{state.get('doc_plan', '')}", + f"Validation feedback:\n{state.get('validation_feedback', '')}", + f"Changed files:\n{file_lines}", + ] + ) + try: + summary = self._llm.generate("docs_execution_summary", user_input).strip() + except Exception: + summary = "" + if not summary: + summary = self._build_fallback_summary(state, file_lines) + return {"answer": summary} + + def _build_fallback_bundle_from_text(self, text: str) -> list[dict]: + content = (text or "").strip() + if not content: + content = ( + "# Project Documentation Draft\n\n" + "## Overview\n" + "Documentation draft was generated, but structured sections require уточнение.\n" + ) + return [ + { + "path": "docs/logic/project_overview.md", + "content": content, + "reason": "Fallback: generated structured logic document from non-JSON model output.", + }, + { + "path": "docs/api/README.md", + "content": ( + "# API Methods\n\n" + "This file is a fallback placeholder for API method documentation.\n\n" + "## Next Step\n" + "- Add one file per API method under `docs/api/`.\n" + ), + "reason": "Fallback: ensure required docs/api structure exists.", + }, + ] + + def _format_changed_files(self, changeset: list[ChangeItem]) -> str: + lines: list[str] = [] + for item in changeset[:30]: + lines.append(f"- {item.op.value} {item.path}: {item.reason}") + return "\n".join(lines) + + def _build_fallback_summary(self, state: AgentGraphState, file_lines: str) -> str: + request = (state.get("message", "") or "").strip() + return "\n".join( + [ + "Выполненные действия:", + f"- Обработан запрос: {request or '(пустой запрос)'}", + f"- Применена стратегия документации: {state.get('docs_strategy', 'from_scratch')}", + "- Сформирован и проверен changeset для документации.", + "", + "Измененные файлы:", + file_lines or "- (нет изменений)", + ] + ) + + def _is_broad_rewrite_request(self, message: str) -> bool: + low = (message or "").lower() + markers = ( + "перепиши", + "полностью", + "целиком", + "с нуля", + "full rewrite", + "rewrite all", + "реорганизуй", + ) + return any(marker in low for marker in markers) diff --git a/app/modules/agent/engine/graphs/file_targeting.py b/app/modules/agent/engine/graphs/file_targeting.py new file mode 100644 index 0000000..6f14659 --- /dev/null +++ b/app/modules/agent/engine/graphs/file_targeting.py @@ -0,0 +1,28 @@ +import re + + +class FileTargeting: + _path_pattern = re.compile(r"([A-Za-z0-9_.\-/]+?\.[A-Za-z0-9_]+)") + + def extract_target_path(self, message: str) -> str | None: + text = (message or "").replace("\\", "/") + candidates = self._path_pattern.findall(text) + if not candidates: + return None + for candidate in candidates: + cleaned = candidate.strip("`'\".,:;()[]{}") + if "/" in cleaned or cleaned.startswith("."): + return cleaned + return candidates[0].strip("`'\".,:;()[]{}") + + def lookup_file(self, files_map: dict[str, dict], path: str | None) -> dict | None: + if not path: + return None + normalized = path.replace("\\", "/") + if normalized in files_map: + return files_map[normalized] + low = normalized.lower() + for key, value in files_map.items(): + if key.lower() == low: + return value + return None diff --git a/app/modules/agent/engine/graphs/progress.py b/app/modules/agent/engine/graphs/progress.py new file mode 100644 index 0000000..7fe878a --- /dev/null +++ b/app/modules/agent/engine/graphs/progress.py @@ -0,0 +1,44 @@ +from collections.abc import Awaitable, Callable +import inspect +import asyncio + +from app.modules.agent.engine.graphs.progress_registry import progress_registry +from app.modules.agent.engine.graphs.state import AgentGraphState + +ProgressCallback = Callable[[str, str, str, dict | None], Awaitable[None] | None] + + +async def emit_progress( + state: AgentGraphState, + *, + stage: str, + message: str, + kind: str = "task_progress", + meta: dict | None = None, +) -> None: + callback = progress_registry.get(state.get("progress_key")) + if callback is None: + return + result = callback(stage, message, kind, meta or {}) + if inspect.isawaitable(result): + await result + + +def emit_progress_sync( + state: AgentGraphState, + *, + stage: str, + message: str, + kind: str = "task_progress", + meta: dict | None = None, +) -> None: + callback = progress_registry.get(state.get("progress_key")) + if callback is None: + return + result = callback(stage, message, kind, meta or {}) + if inspect.isawaitable(result): + try: + loop = asyncio.get_running_loop() + loop.create_task(result) + except RuntimeError: + pass diff --git a/app/modules/agent/engine/graphs/progress_registry.py b/app/modules/agent/engine/graphs/progress_registry.py new file mode 100644 index 0000000..91648d0 --- /dev/null +++ b/app/modules/agent/engine/graphs/progress_registry.py @@ -0,0 +1,27 @@ +from collections.abc import Awaitable, Callable +from threading import Lock + +ProgressCallback = Callable[[str, str, str, dict | None], Awaitable[None] | None] + + +class ProgressRegistry: + def __init__(self) -> None: + self._items: dict[str, ProgressCallback] = {} + self._lock = Lock() + + def register(self, key: str, callback: ProgressCallback) -> None: + with self._lock: + self._items[key] = callback + + def get(self, key: str | None) -> ProgressCallback | None: + if not key: + return None + with self._lock: + return self._items.get(key) + + def unregister(self, key: str) -> None: + with self._lock: + self._items.pop(key, None) + + +progress_registry = ProgressRegistry() diff --git a/app/modules/agent/engine/graphs/project_edits_graph.py b/app/modules/agent/engine/graphs/project_edits_graph.py new file mode 100644 index 0000000..c390847 --- /dev/null +++ b/app/modules/agent/engine/graphs/project_edits_graph.py @@ -0,0 +1,79 @@ +from langgraph.graph import END, START, StateGraph + +from app.modules.agent.engine.graphs.progress import emit_progress_sync +from app.modules.agent.engine.graphs.project_edits_logic import ProjectEditsLogic +from app.modules.agent.engine.graphs.state import AgentGraphState +from app.modules.agent.llm import AgentLlmService + + +class ProjectEditsGraphFactory: + _max_validation_attempts = 2 + + def __init__(self, llm: AgentLlmService) -> None: + self._logic = ProjectEditsLogic(llm) + + def build(self, checkpointer=None): + graph = StateGraph(AgentGraphState) + graph.add_node("collect_context", self._collect_context) + graph.add_node("plan_changes", self._plan_changes) + graph.add_node("generate_changeset", self._generate_changeset) + graph.add_node("self_check", self._self_check) + graph.add_node("build_result", self._build_result) + + graph.add_edge(START, "collect_context") + graph.add_edge("collect_context", "plan_changes") + graph.add_edge("plan_changes", "generate_changeset") + graph.add_edge("generate_changeset", "self_check") + graph.add_conditional_edges( + "self_check", + self._route_after_self_check, + {"retry": "generate_changeset", "ready": "build_result"}, + ) + graph.add_edge("build_result", END) + return graph.compile(checkpointer=checkpointer) + + def _collect_context(self, state: AgentGraphState) -> dict: + emit_progress_sync( + state, + stage="graph.project_edits.collect_context", + message="Собираю контекст и релевантные файлы для правок.", + ) + return self._logic.collect_context(state) + + def _plan_changes(self, state: AgentGraphState) -> dict: + emit_progress_sync( + state, + stage="graph.project_edits.plan_changes", + message="Определяю, что именно нужно изменить и в каких файлах.", + ) + return self._logic.plan_changes(state) + + def _generate_changeset(self, state: AgentGraphState) -> dict: + emit_progress_sync( + state, + stage="graph.project_edits.generate_changeset", + message="Формирую предлагаемые правки по выбранным файлам.", + ) + return self._logic.generate_changeset(state) + + def _self_check(self, state: AgentGraphState) -> dict: + emit_progress_sync( + state, + stage="graph.project_edits.self_check", + message="Проверяю, что правки соответствуют запросу и не трогают лишнее.", + ) + return self._logic.self_check(state) + + def _build_result(self, state: AgentGraphState) -> dict: + emit_progress_sync( + state, + stage="graph.project_edits.build_result", + message="Формирую итоговый changeset и краткий обзор.", + ) + return self._logic.build_result(state) + + def _route_after_self_check(self, state: AgentGraphState) -> str: + if state.get("validation_passed"): + return "ready" + attempts = int(state.get("validation_attempts", 0) or 0) + return "ready" if attempts >= self._max_validation_attempts else "retry" diff --git a/app/modules/agent/engine/graphs/project_edits_logic.py b/app/modules/agent/engine/graphs/project_edits_logic.py new file mode 100644 index 0000000..47bce45 --- /dev/null +++ b/app/modules/agent/engine/graphs/project_edits_logic.py @@ -0,0 +1,271 @@ +import json +from difflib import SequenceMatcher +import re + +from app.modules.agent.engine.graphs.file_targeting import FileTargeting +from app.modules.agent.engine.graphs.state import AgentGraphState +from app.modules.agent.llm import AgentLlmService +from app.schemas.changeset import ChangeItem + + +class ProjectEditsSupport: + def __init__(self, max_context_files: int = 12, max_preview_chars: int = 2500) -> None: + self._max_context_files = max_context_files + self._max_preview_chars = max_preview_chars + + def pick_relevant_files(self, message: str, files_map: dict[str, dict]) -> list[dict]: + tokens = {x for x in (message or "").lower().replace("/", " ").split() if len(x) >= 4} + scored: list[tuple[int, dict]] = [] + for path, payload in files_map.items(): + content = str(payload.get("content", "")) + score = 0 + low_path = path.lower() + low_content = content.lower() + for token in tokens: + if token in low_path: + score += 3 + if token in low_content: + score += 1 + scored.append((score, self.as_candidate(payload))) + scored.sort(key=lambda x: (-x[0], x[1]["path"])) + return [item for _, item in scored[: self._max_context_files]] + + def as_candidate(self, payload: dict) -> dict: + return { + "path": str(payload.get("path", "")).replace("\\", "/"), + "content": str(payload.get("content", "")), + "content_hash": str(payload.get("content_hash", "")), + } + + def build_summary(self, state: AgentGraphState, changeset: list[ChangeItem]) -> str: + if not changeset: + return "Правки не сформированы: changeset пуст." + lines = [ + "Выполненные действия:", + f"- Проанализирован запрос: {state.get('message', '')}", + "- Собран контекст проекта и выбран набор файлов для правок.", + f"- Проведен self-check: {state.get('validation_feedback', 'без замечаний')}", + "", + "Измененные файлы:", + ] + for item in changeset[:30]: + lines.append(f"- {item.op.value} {item.path}: {item.reason}") + return "\n".join(lines) + + def normalize_file_output(self, text: str) -> str: + value = (text or "").strip() + if value.startswith("```") and value.endswith("```"): + lines = value.splitlines() + if len(lines) >= 3: + return "\n".join(lines[1:-1]).strip() + return value + + def parse_json(self, raw: str): + text = self.normalize_file_output(raw) + try: + return json.loads(text) + except Exception: + return {} + + def similarity(self, original: str, updated: str) -> float: + return SequenceMatcher(None, original or "", updated or "").ratio() + + def shorten(self, text: str, max_chars: int | None = None) -> str: + limit = max_chars or self._max_preview_chars + value = (text or "").strip() + if len(value) <= limit: + return value + return value[:limit].rstrip() + "\n...[truncated]" + + def collapse_whitespace(self, text: str) -> str: + return re.sub(r"\s+", " ", (text or "").strip()) + + def line_change_ratio(self, original: str, updated: str) -> float: + orig_lines = (original or "").splitlines() + new_lines = (updated or "").splitlines() + if not orig_lines and not new_lines: + return 0.0 + matcher = SequenceMatcher(None, orig_lines, new_lines) + changed = 0 + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + continue + changed += max(i2 - i1, j2 - j1) + total = max(len(orig_lines), len(new_lines), 1) + return changed / total + + def added_headings(self, original: str, updated: str) -> int: + old_heads = {line.strip() for line in (original or "").splitlines() if line.strip().startswith("#")} + new_heads = {line.strip() for line in (updated or "").splitlines() if line.strip().startswith("#")} + return len(new_heads - old_heads) + + +class ProjectEditsLogic: + def __init__(self, llm: AgentLlmService) -> None: + self._llm = llm + self._targeting = FileTargeting() + self._support = ProjectEditsSupport() + + def collect_context(self, state: AgentGraphState) -> dict: + message = state.get("message", "") + files_map = state.get("files_map", {}) or {} + requested_path = self._targeting.extract_target_path(message) + preferred = self._targeting.lookup_file(files_map, requested_path) if requested_path else None + candidates = self._support.pick_relevant_files(message, files_map) + if preferred and not any(x["path"] == preferred.get("path") for x in candidates): + candidates.insert(0, self._support.as_candidate(preferred)) + return { + "edits_requested_path": str((preferred or {}).get("path") or (requested_path or "")).strip(), + "edits_context_files": candidates[:12], + "validation_attempts": 0, + } + + def plan_changes(self, state: AgentGraphState) -> dict: + context_files = state.get("edits_context_files", []) or [] + user_input = json.dumps( + { + "request": state.get("message", ""), + "requested_path": state.get("edits_requested_path", ""), + "context_files": [ + { + "path": item.get("path", ""), + "content_preview": self._support.shorten(str(item.get("content", ""))), + } + for item in context_files + ], + }, + ensure_ascii=False, + ) + parsed = self._support.parse_json(self._llm.generate("project_edits_plan", user_input)) + files = parsed.get("files", []) if isinstance(parsed, dict) else [] + planned: list[dict] = [] + for item in files[:8] if isinstance(files, list) else []: + if not isinstance(item, dict): + continue + path = str(item.get("path", "")).replace("\\", "/").strip() + if not path: + continue + planned.append( + { + "path": path, + "reason": str(item.get("reason", "")).strip() or "Requested user adjustment.", + } + ) + if not planned: + fallback_path = state.get("edits_requested_path", "").strip() or "docs/REQUESTED_UPDATES.md" + planned = [{"path": fallback_path, "reason": "Fallback path from user request."}] + return {"edits_plan": planned} + + def generate_changeset(self, state: AgentGraphState) -> dict: + files_map = state.get("files_map", {}) or {} + planned = state.get("edits_plan", []) or [] + changeset: list[ChangeItem] = [] + for item in planned: + path = str(item.get("path", "")).replace("\\", "/").strip() + if not path: + continue + current = self._targeting.lookup_file(files_map, path) + current_content = str((current or {}).get("content", "")) + user_input = json.dumps( + { + "request": state.get("message", ""), + "path": path, + "reason": item.get("reason", ""), + "current_content": current_content, + "previous_validation_feedback": state.get("validation_feedback", ""), + "rag_context": self._support.shorten(state.get("rag_context", ""), 5000), + "confluence_context": self._support.shorten(state.get("confluence_context", ""), 5000), + "instruction": "Modify only required parts and preserve unrelated content unchanged.", + }, + ensure_ascii=False, + ) + raw = self._llm.generate("project_edits_apply", user_input).strip() + normalized = self._support.normalize_file_output(raw) + if not normalized: + continue + if current: + if normalized == current_content: + continue + if self._support.collapse_whitespace(normalized) == self._support.collapse_whitespace(current_content): + continue + reason = str(item.get("reason", "")).strip() or "User-requested update." + if current and current.get("content_hash"): + changeset.append( + ChangeItem( + op="update", + path=str(current.get("path") or path), + base_hash=str(current.get("content_hash", "")), + proposed_content=normalized, + reason=reason, + ) + ) + else: + changeset.append(ChangeItem(op="create", path=path, proposed_content=normalized, reason=reason)) + return {"changeset": changeset} + + def self_check(self, state: AgentGraphState) -> dict: + attempts = int(state.get("validation_attempts", 0) or 0) + 1 + changeset = state.get("changeset", []) or [] + files_map = state.get("files_map", {}) or {} + is_broad_rewrite = self._is_broad_rewrite_request(str(state.get("message", ""))) + if not changeset: + return {"validation_attempts": attempts, "validation_passed": False, "validation_feedback": "Generated changeset is empty."} + + for item in changeset: + if item.op.value != "update": + continue + source = self._targeting.lookup_file(files_map, item.path) + if not source: + continue + original = str(source.get("content", "")) + proposed = item.proposed_content or "" + similarity = self._support.similarity(original, proposed) + change_ratio = self._support.line_change_ratio(original, proposed) + headings_added = self._support.added_headings(original, proposed) + min_similarity = 0.75 if is_broad_rewrite else 0.9 + max_change_ratio = 0.7 if is_broad_rewrite else 0.35 + if similarity < min_similarity: + return { + "validation_attempts": attempts, + "validation_passed": False, + "validation_feedback": f"File {item.path} changed too aggressively (similarity={similarity:.2f}).", + } + if change_ratio > max_change_ratio: + return { + "validation_attempts": attempts, + "validation_passed": False, + "validation_feedback": f"File {item.path} changed too broadly (change_ratio={change_ratio:.2f}).", + } + if not is_broad_rewrite and headings_added > 0: + return { + "validation_attempts": attempts, + "validation_passed": False, + "validation_feedback": f"File {item.path} adds new sections outside requested scope.", + } + + payload = { + "request": state.get("message", ""), + "changeset": [{"op": x.op.value, "path": x.path, "reason": x.reason} for x in changeset[:20]], + "rule": "Changes must match request and avoid unrelated modifications.", + } + parsed = self._support.parse_json(self._llm.generate("project_edits_self_check", json.dumps(payload, ensure_ascii=False))) + passed = bool(parsed.get("pass")) if isinstance(parsed, dict) else False + feedback = str(parsed.get("feedback", "")).strip() if isinstance(parsed, dict) else "" + return {"validation_attempts": attempts, "validation_passed": passed, "validation_feedback": feedback or "No feedback provided."} + + def build_result(self, state: AgentGraphState) -> dict: + changeset = state.get("changeset", []) or [] + return {"changeset": changeset, "answer": self._support.build_summary(state, changeset)} + + def _is_broad_rewrite_request(self, message: str) -> bool: + low = (message or "").lower() + markers = ( + "перепиши", + "полностью", + "целиком", + "с нуля", + "full rewrite", + "rewrite all", + "реорганизуй документ", + ) + return any(marker in low for marker in markers) diff --git a/app/modules/agent/engine/graphs/project_qa_graph.py b/app/modules/agent/engine/graphs/project_qa_graph.py new file mode 100644 index 0000000..681543f --- /dev/null +++ b/app/modules/agent/engine/graphs/project_qa_graph.py @@ -0,0 +1,38 @@ +from langgraph.graph import END, START, StateGraph + +from app.modules.agent.engine.graphs.progress import emit_progress_sync +from app.modules.agent.engine.graphs.state import AgentGraphState +from app.modules.agent.llm import AgentLlmService + + +class ProjectQaGraphFactory: + def __init__(self, llm: AgentLlmService) -> None: + self._llm = llm + + def build(self, checkpointer=None): + graph = StateGraph(AgentGraphState) + graph.add_node("answer", self._answer_node) + graph.add_edge(START, "answer") + graph.add_edge("answer", END) + return graph.compile(checkpointer=checkpointer) + + def _answer_node(self, state: AgentGraphState) -> dict: + emit_progress_sync( + state, + stage="graph.project_qa.answer", + message="Готовлю ответ по контексту текущего проекта.", + ) + user_input = "\n\n".join( + [ + f"User request:\n{state.get('message', '')}", + f"RAG context:\n{state.get('rag_context', '')}", + f"Confluence context:\n{state.get('confluence_context', '')}", + ] + ) + answer = self._llm.generate("project_answer", user_input) + emit_progress_sync( + state, + stage="graph.project_qa.answer.done", + message="Ответ по проекту сформирован.", + ) + return {"answer": answer} diff --git a/app/modules/agent/engine/graphs/state.py b/app/modules/agent/engine/graphs/state.py new file mode 100644 index 0000000..14e63da --- /dev/null +++ b/app/modules/agent/engine/graphs/state.py @@ -0,0 +1,32 @@ +from typing import TypedDict + +from app.schemas.changeset import ChangeItem + + +class AgentGraphState(TypedDict, total=False): + task_id: str + project_id: str + message: str + progress_key: str + rag_context: str + confluence_context: str + files_map: dict[str, dict] + docs_candidates: list[dict] + target_path: str + target_file_content: str + target_file_hash: str + existing_docs_detected: bool + existing_docs_summary: str + docs_strategy: str + rules_bundle: str + doc_plan: str + generated_doc: str + generated_docs_bundle: list[dict] + validation_passed: bool + validation_feedback: str + validation_attempts: int + answer: str + changeset: list[ChangeItem] + edits_requested_path: str + edits_context_files: list[dict] + edits_plan: list[dict] diff --git a/app/modules/agent/engine/router/__init__.py b/app/modules/agent/engine/router/__init__.py new file mode 100644 index 0000000..cc36c49 --- /dev/null +++ b/app/modules/agent/engine/router/__init__.py @@ -0,0 +1,34 @@ +from pathlib import Path + +from app.modules.agent.engine.graphs import ( + BaseGraphFactory, + DocsGraphFactory, + ProjectEditsGraphFactory, + ProjectQaGraphFactory, +) +from app.modules.agent.repository import AgentRepository +from app.modules.agent.llm import AgentLlmService +from app.modules.agent.engine.router.context_store import RouterContextStore +from app.modules.agent.engine.router.intent_classifier import IntentClassifier +from app.modules.agent.engine.router.registry import IntentRegistry +from app.modules.agent.engine.router.router_service import RouterService + + +def build_router_service(llm: AgentLlmService, agent_repository: AgentRepository) -> RouterService: + registry_path = Path(__file__).resolve().parent / "intents_registry.yaml" + registry = IntentRegistry(registry_path=registry_path) + registry.register("default", "general", BaseGraphFactory(llm).build) + registry.register("project", "qa", ProjectQaGraphFactory(llm).build) + registry.register("project", "edits", ProjectEditsGraphFactory(llm).build) + registry.register("docs", "generation", DocsGraphFactory(llm).build) + + classifier = IntentClassifier(llm) + context_store = RouterContextStore(agent_repository) + return RouterService( + registry=registry, + classifier=classifier, + context_store=context_store, + ) + + +__all__ = ["build_router_service", "IntentRegistry", "RouterService"] diff --git a/app/modules/agent/engine/router/__pycache__/__init__.cpython-312.pyc b/app/modules/agent/engine/router/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..1284b7e Binary files /dev/null and b/app/modules/agent/engine/router/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/agent/engine/router/__pycache__/context_store.cpython-312.pyc b/app/modules/agent/engine/router/__pycache__/context_store.cpython-312.pyc new file mode 100644 index 0000000..6d415f4 Binary files /dev/null and b/app/modules/agent/engine/router/__pycache__/context_store.cpython-312.pyc differ diff --git a/app/modules/agent/engine/router/__pycache__/intent_classifier.cpython-312.pyc b/app/modules/agent/engine/router/__pycache__/intent_classifier.cpython-312.pyc new file mode 100644 index 0000000..915280e Binary files /dev/null and b/app/modules/agent/engine/router/__pycache__/intent_classifier.cpython-312.pyc differ diff --git a/app/modules/agent/engine/router/__pycache__/registry.cpython-312.pyc b/app/modules/agent/engine/router/__pycache__/registry.cpython-312.pyc new file mode 100644 index 0000000..e7a4c2d Binary files /dev/null and b/app/modules/agent/engine/router/__pycache__/registry.cpython-312.pyc differ diff --git a/app/modules/agent/engine/router/__pycache__/router_service.cpython-312.pyc b/app/modules/agent/engine/router/__pycache__/router_service.cpython-312.pyc new file mode 100644 index 0000000..c544c9c Binary files /dev/null and b/app/modules/agent/engine/router/__pycache__/router_service.cpython-312.pyc differ diff --git a/app/modules/agent/engine/router/__pycache__/schemas.cpython-312.pyc b/app/modules/agent/engine/router/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000..04d5bd4 Binary files /dev/null and b/app/modules/agent/engine/router/__pycache__/schemas.cpython-312.pyc differ diff --git a/app/modules/agent/engine/router/context_store.py b/app/modules/agent/engine/router/context_store.py new file mode 100644 index 0000000..c13c500 --- /dev/null +++ b/app/modules/agent/engine/router/context_store.py @@ -0,0 +1,29 @@ +from app.modules.agent.repository import AgentRepository +from app.modules.agent.engine.router.schemas import RouterContext + + +class RouterContextStore: + def __init__(self, repository: AgentRepository) -> None: + self._repo = repository + + def get(self, conversation_key: str) -> RouterContext: + return self._repo.get_router_context(conversation_key) + + def update( + self, + conversation_key: str, + *, + domain_id: str, + process_id: str, + user_message: str, + assistant_message: str, + max_history: int = 10, + ) -> None: + self._repo.update_router_context( + conversation_key, + domain_id=domain_id, + process_id=process_id, + user_message=user_message, + assistant_message=assistant_message, + max_history=max_history, + ) diff --git a/app/modules/agent/engine/router/intent_classifier.py b/app/modules/agent/engine/router/intent_classifier.py new file mode 100644 index 0000000..e478a8e --- /dev/null +++ b/app/modules/agent/engine/router/intent_classifier.py @@ -0,0 +1,191 @@ +import json +import re + +from app.modules.agent.engine.router.schemas import RouteDecision, RouterContext +from app.modules.agent.llm import AgentLlmService + + +class IntentClassifier: + _short_confirmations = {"да", "ок", "делай", "поехали", "запускай"} + _route_mapping = { + "default/general": ("default", "general"), + "project/qa": ("project", "qa"), + "project/edits": ("project", "edits"), + "docs/generation": ("docs", "generation"), + } + + def __init__(self, llm: AgentLlmService) -> None: + self._llm = llm + + def classify(self, user_message: str, context: RouterContext, mode: str = "auto") -> RouteDecision: + forced = self._from_mode(mode) + if forced: + return forced + + text = (user_message or "").strip().lower() + if text in self._short_confirmations and context.last_routing: + return RouteDecision( + domain_id=context.last_routing["domain_id"], + process_id=context.last_routing["process_id"], + confidence=1.0, + reason="short_confirmation", + use_previous=True, + ) + + deterministic = self._deterministic_route(text) + if deterministic: + return deterministic + + llm_decision = self._classify_with_llm(user_message, context) + if llm_decision: + return llm_decision + + return RouteDecision( + domain_id="default", + process_id="general", + confidence=0.8, + reason="default", + ) + + def _from_mode(self, mode: str) -> RouteDecision | None: + mapping = { + "project_qa": ("project", "qa"), + "project_edits": ("project", "edits"), + "docs_generation": ("docs", "generation"), + # Legacy aliases kept for API compatibility. + "analytics_review": ("project", "qa"), + "code_change": ("project", "edits"), + "qa": ("default", "general"), + } + route = mapping.get((mode or "auto").strip().lower()) + if not route: + return None + return RouteDecision( + domain_id=route[0], + process_id=route[1], + confidence=1.0, + reason=f"mode_override:{mode}", + ) + + def _classify_with_llm(self, user_message: str, context: RouterContext) -> RouteDecision | None: + history = context.message_history[-8:] + user_input = json.dumps( + { + "message": user_message, + "history": history, + "allowed_routes": list(self._route_mapping.keys()), + }, + ensure_ascii=False, + ) + try: + raw = self._llm.generate("router_intent", user_input).strip() + except Exception: + return None + + payload = self._parse_llm_payload(raw) + if not payload: + return None + + route = self._route_mapping.get(payload["route"]) + if not route: + return None + + confidence = self._normalize_confidence(payload.get("confidence")) + return RouteDecision( + domain_id=route[0], + process_id=route[1], + confidence=confidence, + reason=f"llm_router:{payload.get('reason', 'ok')}", + ) + + def _parse_llm_payload(self, raw: str) -> dict[str, str | float] | None: + candidate = self._strip_code_fence(raw.strip()) + if not candidate: + return None + try: + parsed = json.loads(candidate) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return None + route = str(parsed.get("route", "")).strip().lower() + if not route: + return None + return { + "route": route, + "confidence": parsed.get("confidence"), + "reason": str(parsed.get("reason", "ok")).strip().lower(), + } + + def _normalize_confidence(self, value: object) -> float: + if isinstance(value, (float, int)): + return max(0.0, min(1.0, float(value))) + return 0.75 + + def _strip_code_fence(self, text: str) -> str: + if not text.startswith("```"): + return text + lines = text.splitlines() + if len(lines) < 3: + return text + if lines[-1].strip() != "```": + return text + return "\n".join(lines[1:-1]).strip() + + def _deterministic_route(self, text: str) -> RouteDecision | None: + if self._is_targeted_file_edit_request(text): + return RouteDecision( + domain_id="project", + process_id="edits", + confidence=0.97, + reason="deterministic_targeted_file_edit", + ) + if self._is_broad_docs_request(text): + return RouteDecision( + domain_id="docs", + process_id="generation", + confidence=0.95, + reason="deterministic_docs_generation", + ) + return None + + def _is_targeted_file_edit_request(self, text: str) -> bool: + if not text: + return False + edit_markers = ( + "добавь", + "добавить", + "измени", + "исправь", + "обнови", + "удали", + "замени", + "вставь", + "в конец", + "в начале", + "append", + "update", + "edit", + "remove", + "replace", + ) + has_edit_marker = any(marker in text for marker in edit_markers) + has_file_marker = ( + "readme" in text + or bool(re.search(r"\b[\w.\-/]+\.(md|txt|rst|yaml|yml|json|toml|ini|cfg)\b", text)) + ) + return has_edit_marker and has_file_marker + + def _is_broad_docs_request(self, text: str) -> bool: + if not text: + return False + docs_markers = ( + "подготовь документац", + "сгенерируй документац", + "создай документац", + "опиши документац", + "generate documentation", + "write documentation", + "docs/", + ) + return any(marker in text for marker in docs_markers) diff --git a/app/modules/agent/engine/router/intents_registry.yaml b/app/modules/agent/engine/router/intents_registry.yaml new file mode 100644 index 0000000..e41c5f3 --- /dev/null +++ b/app/modules/agent/engine/router/intents_registry.yaml @@ -0,0 +1,17 @@ +intents: + - domain_id: "default" + process_id: "general" + description: "General Q&A" + priority: 1 + - domain_id: "project" + process_id: "qa" + description: "Project-specific Q&A with RAG and confluence context" + priority: 2 + - domain_id: "project" + process_id: "edits" + description: "Project file edits from user request with conservative changeset generation" + priority: 3 + - domain_id: "docs" + process_id: "generation" + description: "Documentation generation as changeset" + priority: 2 diff --git a/app/modules/agent/engine/router/registry.py b/app/modules/agent/engine/router/registry.py new file mode 100644 index 0000000..9af1818 --- /dev/null +++ b/app/modules/agent/engine/router/registry.py @@ -0,0 +1,46 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import yaml + + +class IntentRegistry: + def __init__(self, registry_path: Path) -> None: + self._registry_path = registry_path + self._factories: dict[tuple[str, str], Callable[..., Any]] = {} + + def register(self, domain_id: str, process_id: str, factory: Callable[..., Any]) -> None: + self._factories[(domain_id, process_id)] = factory + + def get_factory(self, domain_id: str, process_id: str) -> Callable[..., Any] | None: + return self._factories.get((domain_id, process_id)) + + def is_valid(self, domain_id: str, process_id: str) -> bool: + return self.get_factory(domain_id, process_id) is not None + + def load_intents(self) -> list[dict[str, Any]]: + if not self._registry_path.is_file(): + return [] + with self._registry_path.open("r", encoding="utf-8") as fh: + payload = yaml.safe_load(fh) or {} + intents = payload.get("intents") + if not isinstance(intents, list): + return [] + output: list[dict[str, Any]] = [] + for item in intents: + if not isinstance(item, dict): + continue + domain_id = item.get("domain_id") + process_id = item.get("process_id") + if not isinstance(domain_id, str) or not isinstance(process_id, str): + continue + output.append( + { + "domain_id": domain_id, + "process_id": process_id, + "description": str(item.get("description") or ""), + "priority": int(item.get("priority") or 0), + } + ) + return output diff --git a/app/modules/agent/engine/router/router_service.py b/app/modules/agent/engine/router/router_service.py new file mode 100644 index 0000000..9ebbb84 --- /dev/null +++ b/app/modules/agent/engine/router/router_service.py @@ -0,0 +1,62 @@ +from app.modules.agent.engine.router.context_store import RouterContextStore +from app.modules.agent.engine.router.intent_classifier import IntentClassifier +from app.modules.agent.engine.router.registry import IntentRegistry +from app.modules.agent.engine.router.schemas import RouteResolution + + +class RouterService: + def __init__( + self, + registry: IntentRegistry, + classifier: IntentClassifier, + context_store: RouterContextStore, + min_confidence: float = 0.7, + ) -> None: + self._registry = registry + self._classifier = classifier + self._ctx = context_store + self._min_confidence = min_confidence + + def resolve(self, user_message: str, conversation_key: str, mode: str = "auto") -> RouteResolution: + context = self._ctx.get(conversation_key) + decision = self._classifier.classify(user_message, context, mode=mode) + if decision.confidence < self._min_confidence: + return self._fallback("low_confidence") + if not self._registry.is_valid(decision.domain_id, decision.process_id): + return self._fallback("invalid_route") + return RouteResolution( + domain_id=decision.domain_id, + process_id=decision.process_id, + confidence=decision.confidence, + reason=decision.reason, + fallback_used=False, + ) + + def persist_context( + self, + conversation_key: str, + *, + domain_id: str, + process_id: str, + user_message: str, + assistant_message: str, + ) -> None: + self._ctx.update( + conversation_key, + domain_id=domain_id, + process_id=process_id, + user_message=user_message, + assistant_message=assistant_message, + ) + + def graph_factory(self, domain_id: str, process_id: str): + return self._registry.get_factory(domain_id, process_id) + + def _fallback(self, reason: str) -> RouteResolution: + return RouteResolution( + domain_id="default", + process_id="general", + confidence=0.0, + reason=reason, + fallback_used=True, + ) diff --git a/app/modules/agent/engine/router/schemas.py b/app/modules/agent/engine/router/schemas.py new file mode 100644 index 0000000..0d15b1a --- /dev/null +++ b/app/modules/agent/engine/router/schemas.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel, Field, field_validator + + +class RouteDecision(BaseModel): + domain_id: str = "default" + process_id: str = "general" + confidence: float = 0.0 + reason: str = "" + use_previous: bool = False + + @field_validator("confidence") + @classmethod + def clamp_confidence(cls, value: float) -> float: + return max(0.0, min(1.0, float(value))) + + +class RouteResolution(BaseModel): + domain_id: str + process_id: str + confidence: float + reason: str + fallback_used: bool = False + + +class RouterContext(BaseModel): + last_routing: dict[str, str] | None = None + message_history: list[dict[str, str]] = Field(default_factory=list) diff --git a/app/modules/agent/llm/__init__.py b/app/modules/agent/llm/__init__.py new file mode 100644 index 0000000..5d734d2 --- /dev/null +++ b/app/modules/agent/llm/__init__.py @@ -0,0 +1,3 @@ +from app.modules.agent.llm.service import AgentLlmService + +__all__ = ["AgentLlmService"] diff --git a/app/modules/agent/llm/__pycache__/__init__.cpython-312.pyc b/app/modules/agent/llm/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..b59ce9d Binary files /dev/null and b/app/modules/agent/llm/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/agent/llm/__pycache__/service.cpython-312.pyc b/app/modules/agent/llm/__pycache__/service.cpython-312.pyc new file mode 100644 index 0000000..efb82e1 Binary files /dev/null and b/app/modules/agent/llm/__pycache__/service.cpython-312.pyc differ diff --git a/app/modules/agent/llm/service.py b/app/modules/agent/llm/service.py new file mode 100644 index 0000000..47af66d --- /dev/null +++ b/app/modules/agent/llm/service.py @@ -0,0 +1,14 @@ +from app.modules.agent.prompt_loader import PromptLoader +from app.modules.shared.gigachat.client import GigaChatClient + + +class AgentLlmService: + def __init__(self, client: GigaChatClient, prompts: PromptLoader) -> None: + self._client = client + self._prompts = prompts + + def generate(self, prompt_name: str, user_input: str) -> str: + system_prompt = self._prompts.load(prompt_name) + if not system_prompt: + system_prompt = "You are a helpful assistant." + return self._client.complete(system_prompt=system_prompt, user_prompt=user_input) diff --git a/app/modules/agent/module.py b/app/modules/agent/module.py new file mode 100644 index 0000000..03c547b --- /dev/null +++ b/app/modules/agent/module.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter +from pydantic import BaseModel, HttpUrl + +from app.modules.agent.changeset_validator import ChangeSetValidator +from app.modules.agent.confluence_service import ConfluenceService +from app.modules.agent.llm import AgentLlmService +from app.modules.agent.prompt_loader import PromptLoader +from app.modules.agent.service import GraphAgentRuntime +from app.modules.agent.repository import AgentRepository +from app.modules.contracts import RagRetriever +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 + + +class ConfluenceFetchRequest(BaseModel): + url: HttpUrl + + +class AgentModule: + def __init__(self, rag_retriever: RagRetriever, agent_repository: AgentRepository) -> None: + self.confluence = ConfluenceService() + self.changeset_validator = ChangeSetValidator() + settings = GigaChatSettings.from_env() + token_provider = GigaChatTokenProvider(settings) + client = GigaChatClient(settings, token_provider) + prompt_loader = PromptLoader() + llm = AgentLlmService(client=client, prompts=prompt_loader) + self.runtime = GraphAgentRuntime( + rag=rag_retriever, + confluence=self.confluence, + changeset_validator=self.changeset_validator, + llm=llm, + agent_repository=agent_repository, + ) + + def internal_router(self) -> APIRouter: + router = APIRouter(prefix="/internal/tools/confluence", tags=["internal-confluence"]) + + @router.post("/fetch") + async def fetch_page(request: ConfluenceFetchRequest) -> dict: + return await self.confluence.fetch_page(str(request.url)) + + return router diff --git a/app/modules/agent/prompt_loader.py b/app/modules/agent/prompt_loader.py new file mode 100644 index 0000000..d076296 --- /dev/null +++ b/app/modules/agent/prompt_loader.py @@ -0,0 +1,15 @@ +from pathlib import Path +import os + + +class PromptLoader: + def __init__(self, prompts_dir: Path | None = None) -> None: + base = prompts_dir or Path(__file__).resolve().parent / "prompts" + env_override = os.getenv("AGENT_PROMPTS_DIR", "").strip() + self._dir = Path(env_override) if env_override else base + + def load(self, name: str) -> str: + path = self._dir / f"{name}.txt" + if not path.is_file(): + return "" + return path.read_text(encoding="utf-8").strip() diff --git a/app/modules/agent/prompts/docs_detect.txt b/app/modules/agent/prompts/docs_detect.txt new file mode 100644 index 0000000..ca790a9 --- /dev/null +++ b/app/modules/agent/prompts/docs_detect.txt @@ -0,0 +1,18 @@ +Ты анализируешь, есть ли в проекте существующая документация, в которую нужно встраиваться. + +Оцени входные данные: +- User request +- Requested target path +- Detected documentation candidates (пути и сниппеты) + +Критерии EXISTS=yes: +- Есть хотя бы один релевантный doc-файл, и +- Он по смыслу подходит под запрос пользователя. + +Критерии EXISTS=no: +- Нет релевантных doc-файлов, или +- Есть только нерелевантные/пустые заготовки. + +Верни строго две строки: +EXISTS: yes|no +SUMMARY: <короткое объяснение на 1-2 предложения> diff --git a/app/modules/agent/prompts/docs_examples/from_scratch_example.md b/app/modules/agent/prompts/docs_examples/from_scratch_example.md new file mode 100644 index 0000000..89ca114 --- /dev/null +++ b/app/modules/agent/prompts/docs_examples/from_scratch_example.md @@ -0,0 +1,27 @@ +# Feature X Documentation + +## Goal +Describe how Feature X works and how to integrate it safely. + +## Architecture Overview +- Input enters through HTTP endpoint. +- Request is validated and transformed. +- Worker executes business logic and persists result. + +## Data Flow +1. Client sends request payload. +2. Service validates payload. +3. Domain layer computes output. +4. Repository stores entities. + +## Configuration +- Required environment variables. +- Optional tuning parameters. + +## Deployment Notes +- Migration prerequisites. +- Rollback strategy. + +## Risks and Constraints +- Throughput is bounded by downstream API limits. +- Partial failures require retry-safe handlers. diff --git a/app/modules/agent/prompts/docs_examples/incremental_update_example.md b/app/modules/agent/prompts/docs_examples/incremental_update_example.md new file mode 100644 index 0000000..5a00054 --- /dev/null +++ b/app/modules/agent/prompts/docs_examples/incremental_update_example.md @@ -0,0 +1,21 @@ +# API Client Module + +## Purpose +This document explains how the API client authenticates and retries requests. + +## Current Behavior +- Access token is fetched before outbound request. +- Retry policy uses exponential backoff for transient failures. + +## Recent Increment (v2) +### Added cache for tokens +- Token is cached in memory for a short TTL. +- Cache invalidates on 401 responses. + +### Operational impact +- Reduced auth latency for repetitive calls. +- Fewer token endpoint requests. + +## Limitations +- Single-process cache only. +- No distributed cache synchronization. diff --git a/app/modules/agent/prompts/docs_execution_summary.txt b/app/modules/agent/prompts/docs_execution_summary.txt new file mode 100644 index 0000000..f36f539 --- /dev/null +++ b/app/modules/agent/prompts/docs_execution_summary.txt @@ -0,0 +1,12 @@ +Ты технический писатель и готовишь краткий итог по выполненной задаче документации. + +Верни только markdown-текст без JSON и без лишних вступлений. +Структура ответа: +1) "Что сделано" — 3-6 коротких пунктов по основным частям пользовательского запроса. +2) "Измененные файлы" — список файлов с кратким описанием изменения по каждому файлу. +3) "Ограничения" — добавляй только если в данных есть явные пробелы или ограничения. + +Правила: +- Используй только входные данные. +- Не выдумывай изменения, которых нет в списке changed files. +- Пиши коротко и по делу. diff --git a/app/modules/agent/prompts/docs_generation.txt b/app/modules/agent/prompts/docs_generation.txt new file mode 100644 index 0000000..c764dc1 --- /dev/null +++ b/app/modules/agent/prompts/docs_generation.txt @@ -0,0 +1,53 @@ +Ты senior technical writer и пишешь только проектную документацию в markdown. + +Твоя задача: +1) Если strategy=incremental_update, встроиться в существующую документацию и добавить только недостающий инкремент. +2) Если strategy=from_scratch, создать целостный документ с нуля. + +Правила: +- Опирайся только на входной контекст (request, plan, rag context, current file content, examples bundle). +- Не выдумывай факты о коде, которых нет во входных данных. +- Сохраняй стиль существующего документа при incremental_update. +- Если контекст неполный, отмечай ограничения явно и коротко в отдельном разделе "Ограничения". +- Структура должна быть логичной и пригодной для реального репозитория. +- Агент должен спроектировать структуру папок и файлов документации под правила ниже. +- Документация должна быть разделена минимум на 2 направления: + - отдельная папка для описания методов API; + - отдельная папка для описания логики/требований. +- В одном markdown-файле допускается описание только: + - одного метода API, или + - одного атомарного куска логики/требования. +- Для описания одного метода API используй структуру: + - название метода; + - параметры запроса; + - параметры ответа; + - use case (сценарий последовательности вызова метода); + - функциональные требования (если нужны технические детали). +- Для описания логики используй аналогичный подход: + - сценарий; + - ссылки из шагов сценария на функциональные требования; + - отдельные функциональные требования с техническими деталями. +- Правила для сценариев: + - без объемных шагов; + - каждый шаг краткий, не более 2 предложений; + - если нужны технические детали, вынеси их из шага в отдельное функциональное требование и дай ссылку на него из шага. + +Формат ответа: +- Верни только JSON-объект без пояснений и без markdown-оберток. +- Строгий формат: +{ + "files": [ + { + "path": "docs/api/.md", + "content": "<полное содержимое markdown-файла>", + "reason": "<кратко зачем создан/обновлен файл>" + }, + { + "path": "docs/logic/.md", + "content": "<полное содержимое markdown-файла>", + "reason": "<кратко зачем создан/обновлен файл>" + } + ] +} +- Для from_scratch сформируй несколько файлов и обязательно покрой обе папки: `docs/api` и `docs/logic`. +- Для incremental_update также соблюдай правило атомарности: один файл = один метод API или один атомарный кусок логики/требования. diff --git a/app/modules/agent/prompts/docs_plan_sections.txt b/app/modules/agent/prompts/docs_plan_sections.txt new file mode 100644 index 0000000..a45c5e3 --- /dev/null +++ b/app/modules/agent/prompts/docs_plan_sections.txt @@ -0,0 +1,25 @@ +Ты составляешь план изменений документации перед генерацией текста. + +Вход: +- Strategy +- User request +- Target path +- Current target content (для incremental_update) +- RAG context по коду +- Examples bundle + +Требования к плану: +- Сначала спроектируй структуру папок и файлов документации под формат: + - отдельная папка для API-методов; + - отдельная папка для логики/требований; + - один файл = один метод API или один атомарный кусок логики/требования. +- Для API-файлов закладывай структуру: название метода, параметры запроса, параметры ответа, use case, функциональные требования. +- Для логики закладывай структуру: сценарий, ссылки из шагов на функциональные требования, отдельные функциональные требования. +- Для сценариев закладывай короткие шаги (не более 2 предложений на шаг), а технические детали выноси в функциональные требования. +- Дай нумерованный список разделов будущего документа. +- Для incremental_update отмечай, какие разделы добавить/обновить, не переписывая все целиком. +- Для from_scratch давай полный каркас документа. +- Каждый пункт должен включать краткую цель раздела. +- Если контекст частичный, включи пункт "Ограничения и допущения". + +Формат ответа: только план в markdown, без вступлений и без JSON. diff --git a/app/modules/agent/prompts/docs_self_check.txt b/app/modules/agent/prompts/docs_self_check.txt new file mode 100644 index 0000000..8f5fbb0 --- /dev/null +++ b/app/modules/agent/prompts/docs_self_check.txt @@ -0,0 +1,22 @@ +Ты валидатор качества документации. + +Проверь: +- Соответствие strategy и user request. +- Соответствие generated document плану секций. +- Отсутствие очевидных выдуманных фактов. +- Практическую применимость текста к проекту. +- Для incremental_update: минимально необходимый инкремент без лишнего переписывания. +- Проверку структуры документации: + - есть разбиение по папкам `docs/api` и `docs/logic`; + - один файл описывает только один API-метод или один атомарный кусок логики; + - сценарии состоят из коротких шагов, а технические детали вынесены в функциональные требования. + +Если документ приемлем: +PASS: yes +FEEDBACK: <коротко, что ок> + +Если документ неприемлем: +PASS: no +FEEDBACK: <коротко, что исправить в следующей попытке> + +Верни ровно две строки в этом формате. diff --git a/app/modules/agent/prompts/docs_strategy.txt b/app/modules/agent/prompts/docs_strategy.txt new file mode 100644 index 0000000..f654ea6 --- /dev/null +++ b/app/modules/agent/prompts/docs_strategy.txt @@ -0,0 +1,14 @@ +Ты выбираешь стратегию генерации документации. + +Доступные стратегии: +- incremental_update: дописать недостающий инкремент в существующий документ. +- from_scratch: создать новый документ с нуля. + +Правила выбора: +- Если Existing docs detected=true и это не противоречит user request, выбирай incremental_update. +- Если Existing docs detected=false, выбирай from_scratch. +- Если пользователь явно просит "с нуля", приоритет у from_scratch. +- Если пользователь явно просит "дописать/обновить", приоритет у incremental_update. + +Верни строго одну строку: +STRATEGY: incremental_update|from_scratch diff --git a/app/modules/agent/prompts/general_answer.txt b/app/modules/agent/prompts/general_answer.txt new file mode 100644 index 0000000..b9238c5 --- /dev/null +++ b/app/modules/agent/prompts/general_answer.txt @@ -0,0 +1,3 @@ +Ты инженерный AI-ассистент. Ответь по проекту коротко и по делу. +Если в контексте недостаточно данных, явно укажи пробелы. +Не выдумывай факты, используй только входные данные. diff --git a/app/modules/agent/prompts/project_answer.txt b/app/modules/agent/prompts/project_answer.txt new file mode 100644 index 0000000..c082c1d --- /dev/null +++ b/app/modules/agent/prompts/project_answer.txt @@ -0,0 +1,9 @@ +Ты инженерный AI-ассистент по текущему проекту. + +Сформируй точный ответ на вопрос пользователя, используя только входной контекст. +Приоритет источников: сначала RAG context, затем Confluence context. + +Правила: +- Не выдумывай факты и явно помечай пробелы в данных. +- Отвечай структурировано и коротко. +- Если пользователь просит шаги, дай практичный пошаговый план. diff --git a/app/modules/agent/prompts/project_edits_apply.txt b/app/modules/agent/prompts/project_edits_apply.txt new file mode 100644 index 0000000..09cc113 --- /dev/null +++ b/app/modules/agent/prompts/project_edits_apply.txt @@ -0,0 +1,10 @@ +Ты вносишь правку в один файл по запросу пользователя. +На вход приходит JSON с request, path, reason, current_content, previous_validation_feedback, rag_context, confluence_context. + +Верни только полное итоговое содержимое файла (без JSON). + +Критичные правила: +- Измени только те части, которые нужны по запросу. +- Не переписывай файл целиком без необходимости. +- Сохрани структуру, стиль и все нерелевантные разделы без изменений. +- Если данных недостаточно, внеси минимально безопасную правку и явно отрази ограничение в тексте файла. diff --git a/app/modules/agent/prompts/project_edits_plan.txt b/app/modules/agent/prompts/project_edits_plan.txt new file mode 100644 index 0000000..f0600a7 --- /dev/null +++ b/app/modules/agent/prompts/project_edits_plan.txt @@ -0,0 +1,15 @@ +Ты анализируешь запрос на правки файлов проекта (не про написание нового кода). +На вход приходит JSON с request, requested_path, context_files. + +Верни только JSON: +{ + "files": [ + {"path": "", "reason": ""} + ] +} + +Правила: +- Выбирай только файлы, реально нужные для выполнения запроса. +- Не добавляй лишние файлы. +- Обычно 1-3 файла, максимум 8. +- Если в request указан конкретный файл, включи его в первую очередь. diff --git a/app/modules/agent/prompts/project_edits_self_check.txt b/app/modules/agent/prompts/project_edits_self_check.txt new file mode 100644 index 0000000..bb00a32 --- /dev/null +++ b/app/modules/agent/prompts/project_edits_self_check.txt @@ -0,0 +1,12 @@ +Ты валидируешь changeset правок файла. +На вход приходит JSON с request и changeset (op, path, reason). + +Проверь: +1) изменения соответствуют запросу, +2) нет лишних нерелевантных правок, +3) изменены только действительно нужные файлы, +4) нет косметических правок (пробелы/форматирование без смысла), +5) нет добавления новых секций/заголовков, если это не запрошено явно. + +Верни только JSON: +{"pass": true|false, "feedback": ""} diff --git a/app/modules/agent/prompts/router_intent.txt b/app/modules/agent/prompts/router_intent.txt new file mode 100644 index 0000000..9f8accb --- /dev/null +++ b/app/modules/agent/prompts/router_intent.txt @@ -0,0 +1,23 @@ +Ты классификатор маршрутов агента. +На вход ты получаешь JSON с полями: +- message: текущий запрос пользователя +- history: последние сообщения диалога +- allowed_routes: допустимые маршруты + +Выбери ровно один маршрут из allowed_routes. +Верни только JSON без markdown и пояснений. + +Строгий формат ответа: +{"route":"","confidence":,"reason":""} + +Правила маршрутизации: +- project/qa: пользователь задает вопросы про текущий проект, его код, архитектуру, модули, поведение, ограничения. +- project/edits: пользователь просит внести правки в существующие файлы проекта (контент, конфиги, тексты, шаблоны), без реализации новой кодовой логики. +- docs/generation: пользователь просит подготовить/обновить документацию, инструкции, markdown-материалы. +- default/general: остальные случаи, включая общие вопросы и консультации. + +Приоритет: +- Если в запросе есть явная команда правки конкретного файла (например `README.md`, путь к файлу, "добавь в конец файла"), выбирай project/edits. +- docs/generation выбирай для задач подготовки документации в целом, а не для точечной правки одного файла. + +Если есть сомнения, выбирай default/general и confidence <= 0.6. diff --git a/app/modules/agent/repository.py b/app/modules/agent/repository.py new file mode 100644 index 0000000..552d188 --- /dev/null +++ b/app/modules/agent/repository.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json + +from sqlalchemy import text + +from app.modules.agent.engine.router.schemas import RouterContext +from app.modules.shared.db import get_engine + + +class AgentRepository: + def ensure_tables(self) -> None: + with get_engine().connect() as conn: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS router_context ( + conversation_key VARCHAR(64) PRIMARY KEY, + last_domain_id VARCHAR(64) NULL, + last_process_id VARCHAR(64) NULL, + message_history_json TEXT NOT NULL DEFAULT '[]', + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.commit() + + def get_router_context(self, conversation_key: str) -> RouterContext: + with get_engine().connect() as conn: + row = conn.execute( + text( + """ + SELECT last_domain_id, last_process_id, message_history_json + FROM router_context + WHERE conversation_key = :key + """ + ), + {"key": conversation_key}, + ).fetchone() + + if not row: + return RouterContext() + + history_raw = row[2] or "[]" + try: + history = json.loads(history_raw) + except json.JSONDecodeError: + history = [] + + last = None + if row[0] and row[1]: + last = {"domain_id": str(row[0]), "process_id": str(row[1])} + + clean_history = [] + for item in history if isinstance(history, list) else []: + if not isinstance(item, dict): + continue + role = str(item.get("role") or "") + content = str(item.get("content") or "") + if role in {"user", "assistant"} and content: + clean_history.append({"role": role, "content": content}) + + return RouterContext(last_routing=last, message_history=clean_history) + + def update_router_context( + self, + conversation_key: str, + *, + domain_id: str, + process_id: str, + user_message: str, + assistant_message: str, + max_history: int, + ) -> None: + current = self.get_router_context(conversation_key) + history = list(current.message_history) + if user_message: + history.append({"role": "user", "content": user_message}) + if assistant_message: + history.append({"role": "assistant", "content": assistant_message}) + if max_history > 0: + history = history[-max_history:] + + with get_engine().connect() as conn: + conn.execute( + text( + """ + INSERT INTO router_context ( + conversation_key, last_domain_id, last_process_id, message_history_json + ) VALUES (:key, :domain, :process, :history) + ON CONFLICT (conversation_key) DO UPDATE SET + last_domain_id = EXCLUDED.last_domain_id, + last_process_id = EXCLUDED.last_process_id, + message_history_json = EXCLUDED.message_history_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "key": conversation_key, + "domain": domain_id, + "process": process_id, + "history": json.dumps(history, ensure_ascii=False), + }, + ) + conn.commit() diff --git a/app/modules/agent/service.py b/app/modules/agent/service.py new file mode 100644 index 0000000..a1036ae --- /dev/null +++ b/app/modules/agent/service.py @@ -0,0 +1,296 @@ +from dataclasses import dataclass, field +from collections.abc import Awaitable, Callable +import inspect +import asyncio +import logging +import re + +from app.modules.agent.engine.router import build_router_service +from app.modules.agent.engine.graphs.progress_registry import progress_registry +from app.modules.agent.llm import AgentLlmService +from app.modules.agent.changeset_validator import ChangeSetValidator +from app.modules.agent.confluence_service import ConfluenceService +from app.modules.agent.repository import AgentRepository +from app.modules.contracts import RagRetriever +from app.modules.shared.checkpointer import get_checkpointer +from app.schemas.changeset import ChangeItem +from app.schemas.chat import TaskResultType +from app.core.exceptions import AppError +from app.schemas.common import ModuleName + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class AgentResult: + result_type: TaskResultType + answer: str | None = None + changeset: list[ChangeItem] = field(default_factory=list) + meta: dict = field(default_factory=dict) + + +class GraphAgentRuntime: + def __init__( + self, + rag: RagRetriever, + confluence: ConfluenceService, + changeset_validator: ChangeSetValidator, + llm: AgentLlmService, + agent_repository: AgentRepository, + ) -> None: + self._rag = rag + self._confluence = confluence + self._changeset_validator = changeset_validator + self._router = build_router_service(llm, agent_repository) + self._checkpointer = None + + 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: Callable[[str, str, str, dict | None], Awaitable[None] | None] | None = None, + ) -> AgentResult: + LOGGER.warning( + "GraphAgentRuntime.run started: task_id=%s dialog_session_id=%s mode=%s", + task_id, + dialog_session_id, + mode, + ) + await self._emit_progress(progress_cb, "agent.route", "Определяю тип запроса и подбираю граф.", meta={"mode": mode}) + route = self._router.resolve(message, dialog_session_id, mode=mode) + await self._emit_progress( + progress_cb, + "agent.route.resolved", + "Маршрут выбран, готовлю контекст для выполнения.", + meta={"domain_id": route.domain_id, "process_id": route.process_id}, + ) + graph = self._resolve_graph(route.domain_id, route.process_id) + files_map = self._build_files_map(files) + + await self._emit_progress(progress_cb, "agent.rag", "Собираю релевантный контекст из RAG.") + rag_ctx = await self._rag.retrieve(rag_session_id, message) + await self._emit_progress(progress_cb, "agent.attachments", "Обрабатываю дополнительные вложения.") + conf_pages = await self._fetch_confluence_pages(attachments) + state = { + "task_id": task_id, + "project_id": rag_session_id, + "message": message, + "progress_key": task_id, + "rag_context": self._format_rag(rag_ctx), + "confluence_context": self._format_confluence(conf_pages), + "files_map": files_map, + } + + await self._emit_progress(progress_cb, "agent.graph", "Запускаю выполнение графа.") + if progress_cb is not None: + progress_registry.register(task_id, progress_cb) + try: + result = await asyncio.to_thread( + self._invoke_graph, + graph, + state, + dialog_session_id, + ) + finally: + if progress_cb is not None: + progress_registry.unregister(task_id) + await self._emit_progress(progress_cb, "agent.graph.done", "Граф завершил обработку результата.") + answer = result.get("answer") + changeset = result.get("changeset") or [] + if changeset: + await self._emit_progress(progress_cb, "agent.changeset", "Проверяю и валидирую предложенные изменения.") + changeset = self._enrich_changeset_hashes(changeset, files_map) + changeset = self._sanitize_changeset(changeset, files_map) + if not changeset: + final_answer = (answer or "").strip() or "Предложенные правки были отброшены как нерелевантные или косметические." + await self._emit_progress(progress_cb, "agent.answer", "После фильтрации правок формирую ответ без changeset.") + self._router.persist_context( + dialog_session_id, + domain_id=route.domain_id, + process_id=route.process_id, + user_message=message, + assistant_message=final_answer, + ) + return AgentResult( + result_type=TaskResultType.ANSWER, + answer=final_answer, + meta={ + "route": route.model_dump(), + "used_rag": True, + "used_confluence": bool(conf_pages), + "changeset_filtered_out": True, + }, + ) + validated = self._changeset_validator.validate(task_id, changeset) + final_answer = (answer or "").strip() or None + self._router.persist_context( + dialog_session_id, + domain_id=route.domain_id, + process_id=route.process_id, + user_message=message, + assistant_message=final_answer or f"changeset:{len(validated)}", + ) + final = AgentResult( + result_type=TaskResultType.CHANGESET, + answer=final_answer, + changeset=validated, + meta={"route": route.model_dump(), "used_rag": True, "used_confluence": bool(conf_pages)}, + ) + LOGGER.warning( + "GraphAgentRuntime.run completed: task_id=%s route=%s/%s result_type=%s changeset_items=%s", + task_id, + route.domain_id, + route.process_id, + final.result_type.value, + len(final.changeset), + ) + return final + + final_answer = answer or "" + await self._emit_progress(progress_cb, "agent.answer", "Формирую финальный ответ.") + self._router.persist_context( + dialog_session_id, + domain_id=route.domain_id, + process_id=route.process_id, + user_message=message, + assistant_message=final_answer, + ) + final = AgentResult( + result_type=TaskResultType.ANSWER, + answer=final_answer, + meta={"route": route.model_dump(), "used_rag": True, "used_confluence": bool(conf_pages)}, + ) + LOGGER.warning( + "GraphAgentRuntime.run completed: task_id=%s route=%s/%s result_type=%s answer_len=%s", + task_id, + route.domain_id, + route.process_id, + final.result_type.value, + len(final.answer or ""), + ) + return final + + async def _emit_progress( + self, + progress_cb: Callable[[str, str, str, dict | None], Awaitable[None] | None] | None, + stage: str, + message: str, + *, + kind: str = "task_progress", + meta: dict | None = None, + ) -> None: + if progress_cb is None: + return + result = progress_cb(stage, message, kind, meta or {}) + if inspect.isawaitable(result): + await result + + def _resolve_graph(self, domain_id: str, process_id: str): + if self._checkpointer is None: + self._checkpointer = get_checkpointer() + factory = self._router.graph_factory(domain_id, process_id) + if factory is None: + factory = self._router.graph_factory("default", "general") + if factory is None: + raise RuntimeError("No graph factory configured") + LOGGER.warning("_resolve_graph resolved: domain_id=%s process_id=%s", domain_id, process_id) + return factory(self._checkpointer) + + def _invoke_graph(self, graph, state: dict, dialog_session_id: str): + return graph.invoke( + state, + config={"configurable": {"thread_id": dialog_session_id}}, + ) + + async def _fetch_confluence_pages(self, attachments: list[dict]) -> list[dict]: + pages: list[dict] = [] + for item in attachments: + if item.get("type") == "confluence_url": + pages.append(await self._confluence.fetch_page(item["url"])) + LOGGER.warning("_fetch_confluence_pages completed: pages=%s", len(pages)) + return pages + + def _format_rag(self, items: list[dict]) -> str: + return "\n".join(str(x.get("content", "")) for x in items) + + def _format_confluence(self, pages: list[dict]) -> str: + return "\n".join(str(x.get("content_markdown", "")) for x in pages) + + def _build_files_map(self, files: list[dict]) -> dict[str, dict]: + output: dict[str, dict] = {} + for item in files: + path = str(item.get("path", "")).replace("\\", "/").strip() + if not path: + continue + output[path] = { + "path": path, + "content": str(item.get("content", "")), + "content_hash": str(item.get("content_hash", "")), + } + LOGGER.warning("_build_files_map completed: files=%s", len(output)) + return output + + def _lookup_file(self, files_map: dict[str, dict], path: str) -> dict | None: + normalized = (path or "").replace("\\", "/") + if normalized in files_map: + return files_map[normalized] + low = normalized.lower() + for key, value in files_map.items(): + if key.lower() == low: + return value + return None + + def _enrich_changeset_hashes(self, items: list[ChangeItem], files_map: dict[str, dict]) -> list[ChangeItem]: + enriched: list[ChangeItem] = [] + for item in items: + if item.op.value == "update": + source = self._lookup_file(files_map, item.path) + if not source or not source.get("content_hash"): + raise AppError( + "missing_base_hash", + f"Cannot build update for {item.path}: no file hash in request context", + ModuleName.AGENT, + ) + item.base_hash = str(source["content_hash"]) + enriched.append(item) + LOGGER.warning("_enrich_changeset_hashes completed: items=%s", len(enriched)) + return enriched + + def _sanitize_changeset(self, items: list[ChangeItem], files_map: dict[str, dict]) -> list[ChangeItem]: + sanitized: list[ChangeItem] = [] + dropped_noop = 0 + dropped_ws = 0 + for item in items: + if item.op.value != "update": + sanitized.append(item) + continue + source = self._lookup_file(files_map, item.path) + if not source: + sanitized.append(item) + continue + original = str(source.get("content", "")) + proposed = item.proposed_content or "" + if proposed == original: + dropped_noop += 1 + continue + if self._collapse_whitespace(proposed) == self._collapse_whitespace(original): + dropped_ws += 1 + continue + sanitized.append(item) + if dropped_noop or dropped_ws: + LOGGER.warning( + "_sanitize_changeset dropped items: noop=%s whitespace_only=%s kept=%s", + dropped_noop, + dropped_ws, + len(sanitized), + ) + return sanitized + + def _collapse_whitespace(self, text: str) -> str: + return re.sub(r"\s+", " ", (text or "").strip()) diff --git a/app/modules/application.py b/app/modules/application.py new file mode 100644 index 0000000..f82f165 --- /dev/null +++ b/app/modules/application.py @@ -0,0 +1,31 @@ +from app.modules.agent.module import AgentModule +from app.modules.agent.repository import AgentRepository +from app.modules.chat.repository import ChatRepository +from app.modules.chat.module import ChatModule +from app.modules.rag.repository import RagRepository +from app.modules.rag.module import RagModule +from app.modules.shared.bootstrap import bootstrap_database +from app.modules.shared.event_bus import EventBus +from app.modules.shared.retry_executor import RetryExecutor + + +class ModularApplication: + def __init__(self) -> None: + self.events = EventBus() + self.retry = RetryExecutor() + self.rag_repository = RagRepository() + self.chat_repository = ChatRepository() + self.agent_repository = AgentRepository() + + self.rag = RagModule(event_bus=self.events, retry=self.retry, repository=self.rag_repository) + self.agent = AgentModule(rag_retriever=self.rag.rag, agent_repository=self.agent_repository) + self.chat = ChatModule( + agent_runner=self.agent.runtime, + event_bus=self.events, + retry=self.retry, + rag_sessions=self.rag.sessions, + repository=self.chat_repository, + ) + + def startup(self) -> None: + bootstrap_database(self.rag_repository, self.chat_repository, self.agent_repository) diff --git a/app/modules/chat/__init__.py b/app/modules/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/chat/__pycache__/__init__.cpython-312.pyc b/app/modules/chat/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..267e017 Binary files /dev/null and b/app/modules/chat/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/chat/__pycache__/dialog_store.cpython-312.pyc b/app/modules/chat/__pycache__/dialog_store.cpython-312.pyc new file mode 100644 index 0000000..8389fb6 Binary files /dev/null and b/app/modules/chat/__pycache__/dialog_store.cpython-312.pyc differ diff --git a/app/modules/chat/__pycache__/module.cpython-312.pyc b/app/modules/chat/__pycache__/module.cpython-312.pyc new file mode 100644 index 0000000..f8e125a Binary files /dev/null and b/app/modules/chat/__pycache__/module.cpython-312.pyc differ diff --git a/app/modules/chat/__pycache__/repository.cpython-312.pyc b/app/modules/chat/__pycache__/repository.cpython-312.pyc new file mode 100644 index 0000000..4a957fd Binary files /dev/null and b/app/modules/chat/__pycache__/repository.cpython-312.pyc differ diff --git a/app/modules/chat/__pycache__/service.cpython-312.pyc b/app/modules/chat/__pycache__/service.cpython-312.pyc new file mode 100644 index 0000000..167c100 Binary files /dev/null and b/app/modules/chat/__pycache__/service.cpython-312.pyc differ diff --git a/app/modules/chat/__pycache__/task_store.cpython-312.pyc b/app/modules/chat/__pycache__/task_store.cpython-312.pyc new file mode 100644 index 0000000..45207dd Binary files /dev/null and b/app/modules/chat/__pycache__/task_store.cpython-312.pyc differ diff --git a/app/modules/chat/dialog_store.py b/app/modules/chat/dialog_store.py new file mode 100644 index 0000000..16ad8f6 --- /dev/null +++ b/app/modules/chat/dialog_store.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from uuid import uuid4 + +from app.modules.chat.repository import ChatRepository + + +@dataclass +class DialogSession: + dialog_session_id: str + rag_session_id: str + + +class DialogSessionStore: + def __init__(self, repository: ChatRepository) -> None: + self._repo = repository + + def create(self, rag_session_id: str) -> DialogSession: + session = DialogSession(dialog_session_id=str(uuid4()), rag_session_id=rag_session_id) + self._repo.create_dialog(session.dialog_session_id, session.rag_session_id) + return session + + def get(self, dialog_session_id: str) -> DialogSession | None: + row = self._repo.get_dialog(dialog_session_id) + if not row: + return None + return DialogSession( + dialog_session_id=str(row["dialog_session_id"]), + rag_session_id=str(row["rag_session_id"]), + ) diff --git a/app/modules/chat/module.py b/app/modules/chat/module.py new file mode 100644 index 0000000..6936758 --- /dev/null +++ b/app/modules/chat/module.py @@ -0,0 +1,104 @@ +from fastapi import APIRouter, Header +from fastapi.responses import StreamingResponse + +from app.core.exceptions import AppError +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_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 +from app.schemas.chat import ( + ChatMessageRequest, + DialogCreateRequest, + DialogCreateResponse, + TaskQueuedResponse, + TaskResultResponse, +) +from app.schemas.common import ModuleName + + +class ChatModule: + def __init__( + self, + agent_runner: AgentRunner, + event_bus: EventBus, + retry: RetryExecutor, + rag_sessions: RagSessionStore, + repository: ChatRepository, + ) -> None: + self._rag_sessions = rag_sessions + self.tasks = TaskStore() + self.dialogs = DialogSessionStore(repository) + self.idempotency = IdempotencyStore() + self.events = event_bus + self.chat = ChatOrchestrator( + task_store=self.tasks, + dialogs=self.dialogs, + idempotency=self.idempotency, + runtime=agent_runner, + events=self.events, + retry=retry, + rag_session_exists=lambda rag_session_id: rag_sessions.get(rag_session_id) is not None, + message_sink=repository.add_message, + ) + + def public_router(self) -> APIRouter: + router = APIRouter(tags=["chat"]) + + @router.post("/api/chat/dialogs", response_model=DialogCreateResponse) + async def create_dialog(request: DialogCreateRequest) -> DialogCreateResponse: + if not self._rag_sessions.get(request.rag_session_id): + raise AppError("rag_session_not_found", "RAG session not found", ModuleName.RAG) + dialog = self.dialogs.create(request.rag_session_id) + return DialogCreateResponse( + dialog_session_id=dialog.dialog_session_id, + rag_session_id=dialog.rag_session_id, + ) + + @router.post("/api/chat/messages", response_model=TaskQueuedResponse) + async def send_message( + request: ChatMessageRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + ) -> TaskQueuedResponse: + task = await self.chat.enqueue_message(request, idempotency_key) + return TaskQueuedResponse(task_id=task.task_id, status=task.status.value) + + @router.get("/api/tasks/{task_id}", response_model=TaskResultResponse) + async def get_task(task_id: str) -> TaskResultResponse: + task = self.tasks.get(task_id) + if not task: + raise AppError("not_found", f"Task not found: {task_id}", ModuleName.BACKEND) + return TaskResultResponse( + task_id=task.task_id, + status=task.status, + result_type=task.result_type, + answer=task.answer, + changeset=task.changeset, + error=task.error, + ) + + @router.get("/api/events") + async def stream_events(task_id: str) -> StreamingResponse: + queue = await self.events.subscribe(task_id) + + async def event_stream(): + import asyncio + + heartbeat = 10 + try: + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=heartbeat) + yield EventBus.as_sse(event) + except asyncio.TimeoutError: + yield ": keepalive\\n\\n" + finally: + await self.events.unsubscribe(task_id, queue) + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + return router diff --git a/app/modules/chat/repository.py b/app/modules/chat/repository.py new file mode 100644 index 0000000..e78ff9e --- /dev/null +++ b/app/modules/chat/repository.py @@ -0,0 +1,93 @@ +import json + +from sqlalchemy import text + +from app.modules.shared.db import get_engine + + +class ChatRepository: + def ensure_tables(self) -> None: + with get_engine().connect() as conn: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS dialog_sessions ( + dialog_session_id VARCHAR(64) PRIMARY KEY, + rag_session_id VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS chat_messages ( + id BIGSERIAL PRIMARY KEY, + dialog_session_id VARCHAR(64) NOT NULL, + task_id VARCHAR(64), + role VARCHAR(16) NOT NULL, + content TEXT NOT NULL, + payload JSONB, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute(text("ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS task_id VARCHAR(64)")) + conn.execute(text("ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS payload JSONB")) + conn.commit() + + def create_dialog(self, dialog_session_id: str, rag_session_id: str) -> None: + with get_engine().connect() as conn: + conn.execute( + text( + """ + INSERT INTO dialog_sessions (dialog_session_id, rag_session_id) + VALUES (:did, :sid) + """ + ), + {"did": dialog_session_id, "sid": rag_session_id}, + ) + conn.commit() + + def get_dialog(self, dialog_session_id: str) -> dict | None: + with get_engine().connect() as conn: + row = conn.execute( + text( + """ + SELECT dialog_session_id, rag_session_id + FROM dialog_sessions + WHERE dialog_session_id = :did + """ + ), + {"did": dialog_session_id}, + ).mappings().fetchone() + return dict(row) if row else None + + def add_message( + self, + dialog_session_id: str, + role: str, + content: str, + task_id: str | None = None, + payload: dict | None = None, + ) -> None: + payload_json = json.dumps(payload, ensure_ascii=False) if payload is not None else None + with get_engine().connect() as conn: + conn.execute( + text( + """ + INSERT INTO chat_messages (dialog_session_id, task_id, role, content, payload) + VALUES (:did, :task_id, :role, :content, CAST(:payload AS JSONB)) + """ + ), + { + "did": dialog_session_id, + "task_id": task_id, + "role": role, + "content": content, + "payload": payload_json, + }, + ) + conn.commit() diff --git a/app/modules/chat/service.py b/app/modules/chat/service.py new file mode 100644 index 0000000..f647560 --- /dev/null +++ b/app/modules/chat/service.py @@ -0,0 +1,276 @@ +import asyncio +import logging + +from app.core.exceptions import AppError +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.task_store import TaskState, TaskStore +from app.modules.shared.event_bus import EventBus +from app.modules.shared.idempotency_store import IdempotencyStore +from app.modules.shared.retry_executor import RetryExecutor + +LOGGER = logging.getLogger(__name__) + + +class ChatOrchestrator: + def __init__( + self, + task_store: TaskStore, + dialogs: DialogSessionStore, + idempotency: IdempotencyStore, + runtime: AgentRunner, + events: EventBus, + retry: RetryExecutor, + rag_session_exists, + message_sink, + ) -> None: + self._task_store = task_store + self._dialogs = dialogs + self._idempotency = idempotency + self._runtime = runtime + self._events = events + self._retry = retry + self._rag_session_exists = rag_session_exists + self._message_sink = message_sink + + async def enqueue_message( + self, + request: ChatMessageRequest, + idempotency_key: str | None, + ) -> TaskState: + if idempotency_key: + existing = self._idempotency.get_task_id(idempotency_key) + if existing: + task = self._task_store.get(existing) + if task: + LOGGER.warning( + "enqueue_message reused task by idempotency key: task_id=%s mode=%s", + task.task_id, + request.mode.value, + ) + return task + + task = self._task_store.create() + if idempotency_key: + self._idempotency.put(idempotency_key, task.task_id) + asyncio.create_task(self._process_task(task.task_id, request)) + LOGGER.warning( + "enqueue_message created task: task_id=%s mode=%s", + task.task_id, + request.mode.value, + ) + return task + + async def _process_task(self, task_id: str, request: ChatMessageRequest) -> None: + task = self._task_store.get(task_id) + if not task: + return + task.status = TaskStatus.RUNNING + self._task_store.save(task) + await self._events.publish(task_id, "task_status", {"task_id": task_id, "status": task.status.value}) + await self._publish_progress(task_id, "task.start", "Запрос принят, начинаю обработку.", progress=5) + + heartbeat_stop = asyncio.Event() + heartbeat_task = asyncio.create_task(self._run_heartbeat(task_id, heartbeat_stop)) + + try: + await self._publish_progress(task_id, "task.sessions", "Проверяю сессии диалога и проекта.", progress=10) + dialog_session_id, rag_session_id = self._resolve_sessions(request) + await self._publish_progress(task_id, "task.sessions.done", "Сессии проверены, запускаю агента.", progress=15) + loop = asyncio.get_running_loop() + + def progress_cb(stage: str, message: str, kind: str = "task_progress", meta: dict | None = None): + asyncio.run_coroutine_threadsafe( + self._events.publish( + task_id, + kind, + { + "task_id": task_id, + "stage": stage, + "message": message, + "meta": meta or {}, + }, + ), + loop, + ) + + async def op(): + self._message_sink(dialog_session_id, "user", request.message, task_id=task_id) + await self._publish_progress(task_id, "task.agent.run", "Агент анализирует запрос и готовит ответ.", progress=20) + return await self._runtime.run( + task_id=task_id, + dialog_session_id=dialog_session_id, + rag_session_id=rag_session_id, + mode=request.mode.value, + message=request.message, + attachments=[a.model_dump(mode="json") for a in request.attachments], + files=[f.model_dump(mode="json") for f in request.files], + progress_cb=progress_cb, + ) + + result = await self._retry.run(op) + await self._publish_progress(task_id, "task.finalize", "Сохраняю финальный результат.", progress=95) + task.status = TaskStatus.DONE + task.result_type = TaskResultType(result.result_type) + task.answer = result.answer + 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) + elif task.result_type == TaskResultType.CHANGESET: + self._message_sink( + dialog_session_id, + "assistant", + f"changeset:{len(task.changeset)}", + task_id=task_id, + payload={ + "result_type": TaskResultType.CHANGESET.value, + "changeset": [item.model_dump(mode="json") for item in task.changeset], + }, + ) + self._task_store.save(task) + await self._events.publish( + task_id, + "task_result", + { + "task_id": task_id, + "status": task.status.value, + "result_type": task.result_type.value, + "answer": task.answer, + "changeset": [item.model_dump(mode="json") for item in task.changeset], + "meta": getattr(result, "meta", {}) or {}, + }, + ) + await self._publish_progress(task_id, "task.done", "Обработка завершена.", progress=100) + LOGGER.warning( + "_process_task completed: task_id=%s status=%s result_type=%s changeset_items=%s", + task_id, + task.status.value, + task.result_type.value if task.result_type else "", + len(task.changeset), + ) + except (AppError, TimeoutError, ConnectionError, OSError) as exc: + task.status = TaskStatus.ERROR + if isinstance(exc, AppError): + payload = ErrorPayload(code=exc.code, desc=exc.desc, module=exc.module) + else: + payload = ErrorPayload( + code="retry_exhausted", + desc="Temporary failure after retries. Please retry request.", + module=ModuleName.BACKEND, + ) + task.error = payload + self._task_store.save(task) + await self._publish_progress(task_id, "task.error", "Не удалось завершить обработку запроса.", kind="task_thinking") + await self._events.publish(task_id, "task_error", payload.model_dump(mode="json")) + LOGGER.warning( + "_process_task handled error: task_id=%s code=%s module=%s desc=%s", + task_id, + payload.code, + payload.module.value, + payload.desc, + ) + except Exception: + task.status = TaskStatus.ERROR + payload = ErrorPayload( + code="agent_runtime_error", + desc="Agent execution failed unexpectedly. Please retry request.", + module=ModuleName.AGENT, + ) + task.error = payload + self._task_store.save(task) + await self._publish_progress( + task_id, + "task.error", + "Во время выполнения возникла внутренняя ошибка.", + kind="task_thinking", + ) + await self._events.publish(task_id, "task_error", payload.model_dump(mode="json")) + LOGGER.exception( + "_process_task unexpected error: task_id=%s code=%s", + task_id, + payload.code, + ) + finally: + heartbeat_stop.set() + await heartbeat_task + + async def _publish_progress( + self, + task_id: str, + stage: str, + message: str, + *, + progress: int | None = None, + kind: str = "task_progress", + meta: dict | None = None, + ) -> None: + payload = { + "task_id": task_id, + "stage": stage, + "message": message, + "meta": meta or {}, + } + if progress is not None: + payload["progress"] = max(0, min(100, int(progress))) + await self._events.publish(task_id, kind, payload) + LOGGER.warning( + "_publish_progress emitted: task_id=%s kind=%s stage=%s progress=%s", + task_id, + kind, + stage, + payload.get("progress"), + ) + + async def _run_heartbeat(self, task_id: str, stop_event: asyncio.Event) -> None: + messages = ( + "Собираю данные по проекту.", + "Анализирую контекст и формирую структуру ответа.", + "Проверяю согласованность промежуточного результата.", + ) + index = 0 + while not stop_event.is_set(): + try: + await asyncio.wait_for(stop_event.wait(), timeout=5.0) + except asyncio.TimeoutError: + await self._publish_progress( + task_id, + "task.heartbeat", + messages[index % len(messages)], + kind="task_thinking", + meta={"heartbeat": True}, + ) + index += 1 + LOGGER.warning("_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, + ) diff --git a/app/modules/chat/task_store.py b/app/modules/chat/task_store.py new file mode 100644 index 0000000..7a6453c --- /dev/null +++ b/app/modules/chat/task_store.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass, field +from threading import Lock +from uuid import uuid4 + +from app.schemas.changeset import ChangeItem +from app.schemas.chat import TaskResultType, TaskStatus +from app.schemas.common import ErrorPayload + + +@dataclass +class TaskState: + task_id: str + status: TaskStatus = TaskStatus.QUEUED + result_type: TaskResultType | None = None + answer: str | None = None + changeset: list[ChangeItem] = field(default_factory=list) + error: ErrorPayload | None = None + + +class TaskStore: + def __init__(self) -> None: + self._items: dict[str, TaskState] = {} + self._lock = Lock() + + def create(self) -> TaskState: + task = TaskState(task_id=str(uuid4())) + with self._lock: + self._items[task.task_id] = task + return task + + def get(self, task_id: str) -> TaskState | None: + with self._lock: + return self._items.get(task_id) + + def save(self, task: TaskState) -> None: + with self._lock: + self._items[task.task_id] = task diff --git a/app/modules/contracts.py b/app/modules/contracts.py new file mode 100644 index 0000000..402c14d --- /dev/null +++ b/app/modules/contracts.py @@ -0,0 +1,47 @@ +from typing import Protocol +from collections.abc import Awaitable, Callable + +from app.schemas.changeset import ChangeItem +from app.schemas.chat import TaskResultType + + +class AgentRunResult(Protocol): + result_type: TaskResultType + answer: str | None + changeset: list[ChangeItem] + meta: dict + + +class AgentRunner(Protocol): + 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: Callable[[str, str, str, dict | None], Awaitable[None] | None] | None = None, + ) -> AgentRunResult: ... + + +class RagRetriever(Protocol): + async def retrieve(self, rag_session_id: str, query: str) -> list[dict]: ... + + +class RagIndexer(Protocol): + async def index_snapshot( + self, + rag_session_id: str, + files: list[dict], + progress_cb: Callable[[int, int, str], Awaitable[None] | None] | None = None, + ) -> tuple[int, int]: ... + + async def index_changes( + self, + rag_session_id: str, + changed_files: list[dict], + progress_cb: Callable[[int, int, str], Awaitable[None] | None] | None = None, + ) -> tuple[int, int]: ... diff --git a/app/modules/rag/__init__.py b/app/modules/rag/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/rag/__pycache__/__init__.cpython-312.pyc b/app/modules/rag/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..76b1ff3 Binary files /dev/null and b/app/modules/rag/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/rag/__pycache__/indexing_service.cpython-312.pyc b/app/modules/rag/__pycache__/indexing_service.cpython-312.pyc new file mode 100644 index 0000000..f4f1f64 Binary files /dev/null and b/app/modules/rag/__pycache__/indexing_service.cpython-312.pyc differ diff --git a/app/modules/rag/__pycache__/job_store.cpython-312.pyc b/app/modules/rag/__pycache__/job_store.cpython-312.pyc new file mode 100644 index 0000000..e196f1e Binary files /dev/null and b/app/modules/rag/__pycache__/job_store.cpython-312.pyc differ diff --git a/app/modules/rag/__pycache__/module.cpython-312.pyc b/app/modules/rag/__pycache__/module.cpython-312.pyc new file mode 100644 index 0000000..aeff80b Binary files /dev/null and b/app/modules/rag/__pycache__/module.cpython-312.pyc differ diff --git a/app/modules/rag/__pycache__/repository.cpython-312.pyc b/app/modules/rag/__pycache__/repository.cpython-312.pyc new file mode 100644 index 0000000..dd6d4db Binary files /dev/null and b/app/modules/rag/__pycache__/repository.cpython-312.pyc differ diff --git a/app/modules/rag/__pycache__/service.cpython-312.pyc b/app/modules/rag/__pycache__/service.cpython-312.pyc new file mode 100644 index 0000000..71628b3 Binary files /dev/null and b/app/modules/rag/__pycache__/service.cpython-312.pyc differ diff --git a/app/modules/rag/__pycache__/session_store.cpython-312.pyc b/app/modules/rag/__pycache__/session_store.cpython-312.pyc new file mode 100644 index 0000000..fd56ff5 Binary files /dev/null and b/app/modules/rag/__pycache__/session_store.cpython-312.pyc differ diff --git a/app/modules/rag/embedding/__init__.py b/app/modules/rag/embedding/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/rag/embedding/__pycache__/__init__.cpython-312.pyc b/app/modules/rag/embedding/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..85e6bf7 Binary files /dev/null and b/app/modules/rag/embedding/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/rag/embedding/__pycache__/gigachat_embedder.cpython-312.pyc b/app/modules/rag/embedding/__pycache__/gigachat_embedder.cpython-312.pyc new file mode 100644 index 0000000..7152d29 Binary files /dev/null and b/app/modules/rag/embedding/__pycache__/gigachat_embedder.cpython-312.pyc differ diff --git a/app/modules/rag/embedding/gigachat_embedder.py b/app/modules/rag/embedding/gigachat_embedder.py new file mode 100644 index 0000000..ce74fe2 --- /dev/null +++ b/app/modules/rag/embedding/gigachat_embedder.py @@ -0,0 +1,9 @@ +from app.modules.shared.gigachat.client import GigaChatClient + + +class GigaChatEmbedder: + def __init__(self, client: GigaChatClient) -> None: + self._client = client + + def embed(self, texts: list[str]) -> list[list[float]]: + return self._client.embed(texts) diff --git a/app/modules/rag/indexing_service.py b/app/modules/rag/indexing_service.py new file mode 100644 index 0000000..7b956e3 --- /dev/null +++ b/app/modules/rag/indexing_service.py @@ -0,0 +1,141 @@ +import asyncio +from collections import defaultdict + +from app.schemas.common import ErrorPayload, ModuleName +from app.schemas.indexing import IndexJobStatus +from app.modules.contracts import RagIndexer +from app.modules.rag.job_store import IndexJob, IndexJobStore +from app.modules.shared.event_bus import EventBus +from app.modules.shared.retry_executor import RetryExecutor + + +class IndexingOrchestrator: + def __init__( + self, + store: IndexJobStore, + rag: RagIndexer, + events: EventBus, + retry: RetryExecutor, + ) -> None: + self._store = store + self._rag = rag + self._events = events + self._retry = retry + self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + + async def enqueue_snapshot(self, rag_session_id: str, files: list[dict]) -> IndexJob: + job = self._store.create(rag_session_id) + asyncio.create_task(self._process_snapshot(job.index_job_id, rag_session_id, files)) + return job + + async def enqueue_changes(self, rag_session_id: str, changed_files: list[dict]) -> IndexJob: + job = self._store.create(rag_session_id) + asyncio.create_task(self._process_changes(job.index_job_id, rag_session_id, changed_files)) + return job + + async def _process_snapshot(self, job_id: str, rag_session_id: str, files: list[dict]) -> None: + await self._run_with_project_lock( + job_id=job_id, + rag_session_id=rag_session_id, + total_files=len(files), + operation=lambda progress_cb: self._rag.index_snapshot( + rag_session_id=rag_session_id, + files=files, + progress_cb=progress_cb, + ), + ) + + async def _process_changes(self, job_id: str, rag_session_id: str, changed_files: list[dict]) -> None: + await self._run_with_project_lock( + job_id=job_id, + rag_session_id=rag_session_id, + total_files=len(changed_files), + operation=lambda progress_cb: self._rag.index_changes( + rag_session_id=rag_session_id, + changed_files=changed_files, + progress_cb=progress_cb, + ), + ) + + async def _run_with_project_lock(self, job_id: str, rag_session_id: str, total_files: int, operation) -> None: + lock = self._locks[rag_session_id] + async with lock: + job = self._store.get(job_id) + if not job: + return + job.status = IndexJobStatus.RUNNING + self._store.save(job) + await self._events.publish( + job_id, + "index_status", + {"index_job_id": job_id, "status": job.status.value, "total_files": total_files}, + ) + try: + async def progress_cb(current_file_index: int, total: int, current_file_name: str) -> None: + await self._events.publish( + job_id, + "index_progress", + { + "index_job_id": job_id, + "current_file_index": current_file_index, + "total_files": total, + "processed_files": current_file_index, + "current_file_path": current_file_name, + "current_file_name": current_file_name, + }, + ) + + indexed, failed = await self._retry.run(lambda: operation(progress_cb)) + job.status = IndexJobStatus.DONE + job.indexed_files = indexed + job.failed_files = failed + self._store.save(job) + await self._events.publish( + job_id, + "index_status", + { + "index_job_id": job_id, + "status": job.status.value, + "indexed_files": indexed, + "failed_files": failed, + "total_files": total_files, + }, + ) + await self._events.publish( + job_id, + "terminal", + { + "index_job_id": job_id, + "status": "done", + "indexed_files": indexed, + "failed_files": failed, + "total_files": total_files, + }, + ) + except (TimeoutError, ConnectionError, OSError) as exc: + job.status = IndexJobStatus.ERROR + job.error = ErrorPayload( + code="index_retry_exhausted", + desc=f"Temporary indexing failure after retries: {exc}", + module=ModuleName.RAG, + ) + self._store.save(job) + await self._events.publish( + job_id, + "index_status", + {"index_job_id": job_id, "status": job.status.value, "total_files": total_files}, + ) + await self._events.publish( + job_id, + "terminal", + { + "index_job_id": job_id, + "status": "error", + "total_files": total_files, + "error": { + "code": job.error.code, + "desc": job.error.desc, + "module": job.error.module.value, + }, + }, + ) diff --git a/app/modules/rag/job_store.py b/app/modules/rag/job_store.py new file mode 100644 index 0000000..089e9a8 --- /dev/null +++ b/app/modules/rag/job_store.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass +from uuid import uuid4 + +from app.modules.rag.repository import RagRepository +from app.schemas.common import ErrorPayload, ModuleName +from app.schemas.indexing import IndexJobStatus + + +@dataclass +class IndexJob: + index_job_id: str + rag_session_id: str + status: IndexJobStatus = IndexJobStatus.QUEUED + indexed_files: int = 0 + failed_files: int = 0 + error: ErrorPayload | None = None + + +class IndexJobStore: + def __init__(self, repository: RagRepository) -> None: + self._repo = repository + + def create(self, rag_session_id: str) -> IndexJob: + job = IndexJob(index_job_id=str(uuid4()), rag_session_id=rag_session_id) + self._repo.create_job(job.index_job_id, rag_session_id, job.status.value) + return job + + def get(self, index_job_id: str) -> IndexJob | None: + row = self._repo.get_job(index_job_id) + if not row: + return None + payload = None + if row.error_code: + module = ModuleName.RAG + if row.error_module: + try: + module = ModuleName(row.error_module) + except ValueError: + module = ModuleName.RAG + payload = ErrorPayload( + code=row.error_code, + desc=row.error_desc or "", + module=module, + ) + return IndexJob( + index_job_id=row.index_job_id, + rag_session_id=row.rag_session_id, + status=IndexJobStatus(row.status), + indexed_files=row.indexed_files, + failed_files=row.failed_files, + error=payload, + ) + + def save(self, job: IndexJob) -> None: + error_code = job.error.code if job.error else None + error_desc = job.error.desc if job.error else None + error_module = job.error.module.value if job.error else None + self._repo.update_job( + job.index_job_id, + status=job.status.value, + indexed_files=job.indexed_files, + failed_files=job.failed_files, + error_code=error_code, + error_desc=error_desc, + error_module=error_module, + ) diff --git a/app/modules/rag/module.py b/app/modules/rag/module.py new file mode 100644 index 0000000..84dcb72 --- /dev/null +++ b/app/modules/rag/module.py @@ -0,0 +1,247 @@ +from fastapi import APIRouter +from fastapi.responses import StreamingResponse + +from app.core.exceptions import AppError +from app.modules.rag.embedding.gigachat_embedder import GigaChatEmbedder +from app.modules.rag.indexing_service import IndexingOrchestrator +from app.modules.rag.job_store import IndexJobStore +from app.modules.rag.repository import RagRepository +from app.modules.rag.retrieval.chunker import TextChunker +from app.modules.rag.session_store import RagSessionStore +from app.modules.rag.service import RagService +from app.modules.shared.event_bus import EventBus +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 +from app.modules.shared.retry_executor import RetryExecutor +from app.schemas.common import ModuleName +from app.schemas.indexing import ( + IndexChangesRequest, + IndexJobQueuedResponse, + IndexJobResponse, + IndexSnapshotRequest, +) +from app.schemas.rag_sessions import ( + RagSessionChangesRequest, + RagSessionCreateRequest, + RagSessionCreateResponse, + RagSessionJobResponse, +) + + +class RagModule: + def __init__(self, event_bus: EventBus, retry: RetryExecutor, repository: RagRepository) -> None: + self._events = event_bus + self.repository = repository + settings = GigaChatSettings.from_env() + token_provider = GigaChatTokenProvider(settings) + client = GigaChatClient(settings, token_provider) + embedder = GigaChatEmbedder(client) + self.rag = RagService(embedder=embedder, repository=repository, chunker=TextChunker()) + self.sessions = RagSessionStore(repository) + self.jobs = IndexJobStore(repository) + self.indexing = IndexingOrchestrator( + store=self.jobs, + rag=self.rag, + events=event_bus, + retry=retry, + ) + + def public_router(self) -> APIRouter: + router = APIRouter(tags=["rag"]) + + @router.post("/api/rag/sessions", response_model=RagSessionCreateResponse) + async def create_rag_session(request: RagSessionCreateRequest) -> RagSessionCreateResponse: + session = self.sessions.create(request.project_id) + job = await self.indexing.enqueue_snapshot( + rag_session_id=session.rag_session_id, + files=[x.model_dump() for x in request.files], + ) + return RagSessionCreateResponse( + rag_session_id=session.rag_session_id, + index_job_id=job.index_job_id, + status=job.status, + ) + + @router.post("/api/rag/sessions/{rag_session_id}/changes", response_model=IndexJobQueuedResponse) + async def rag_session_changes( + rag_session_id: str, + request: RagSessionChangesRequest, + ) -> IndexJobQueuedResponse: + session = self.sessions.get(rag_session_id) + if not session: + raise AppError("not_found", f"RAG session not found: {rag_session_id}", ModuleName.RAG) + job = await self.indexing.enqueue_changes( + rag_session_id=rag_session_id, + changed_files=[x.model_dump() for x in request.changed_files], + ) + return IndexJobQueuedResponse(index_job_id=job.index_job_id, status=job.status.value) + + @router.get("/api/rag/sessions/{rag_session_id}/jobs/{index_job_id}", response_model=RagSessionJobResponse) + async def rag_session_job(rag_session_id: str, index_job_id: str) -> RagSessionJobResponse: + job = self.jobs.get(index_job_id) + if not job or job.rag_session_id != rag_session_id: + raise AppError("not_found", f"Index job not found: {index_job_id}", ModuleName.RAG) + return RagSessionJobResponse( + rag_session_id=rag_session_id, + index_job_id=job.index_job_id, + status=job.status, + indexed_files=job.indexed_files, + failed_files=job.failed_files, + error=job.error.model_dump(mode="json") if job.error else None, + ) + + @router.get("/api/rag/sessions/{rag_session_id}/jobs/{index_job_id}/events") + async def rag_session_job_events(rag_session_id: str, index_job_id: str) -> StreamingResponse: + job = self.jobs.get(index_job_id) + if not job or job.rag_session_id != rag_session_id: + raise AppError("not_found", f"Index job not found: {index_job_id}", ModuleName.RAG) + queue = await self._events.subscribe(index_job_id, replay=True) + + async def event_stream(): + import asyncio + + heartbeat = 10 + try: + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=heartbeat) + yield EventBus.as_sse(event) + if event.name == "terminal": + break + except asyncio.TimeoutError: + yield ": keepalive\n\n" + finally: + await self._events.unsubscribe(index_job_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", + }, + ) + + # Legacy compatibility endpoints. + legacy = APIRouter(prefix="/api/index", tags=["index"]) + + @legacy.post("/snapshot", response_model=IndexJobQueuedResponse) + async def index_snapshot(request: IndexSnapshotRequest) -> IndexJobQueuedResponse: + session = self.sessions.put( + rag_session_id=request.project_id, + project_id=request.project_id, + ) + job = await self.indexing.enqueue_snapshot( + rag_session_id=session.rag_session_id, + files=[x.model_dump() for x in request.files], + ) + return IndexJobQueuedResponse(index_job_id=job.index_job_id, status=job.status.value) + + @legacy.post("/changes", response_model=IndexJobQueuedResponse) + async def index_changes(request: IndexChangesRequest) -> IndexJobQueuedResponse: + rag_session_id = request.project_id + if not self.sessions.get(rag_session_id): + self.sessions.put(rag_session_id=rag_session_id, project_id=rag_session_id) + job = await self.indexing.enqueue_changes( + rag_session_id=rag_session_id, + changed_files=[x.model_dump() for x in request.changed_files], + ) + return IndexJobQueuedResponse(index_job_id=job.index_job_id, status=job.status.value) + + @legacy.get("/jobs/{index_job_id}", response_model=IndexJobResponse) + async def get_index_job(index_job_id: str) -> IndexJobResponse: + job = self.jobs.get(index_job_id) + if not job: + raise AppError("not_found", f"Index job not found: {index_job_id}", ModuleName.RAG) + return IndexJobResponse( + index_job_id=job.index_job_id, + status=job.status, + indexed_files=job.indexed_files, + failed_files=job.failed_files, + error=job.error, + ) + + @legacy.get("/jobs/{index_job_id}/events") + async def get_index_job_events(index_job_id: str) -> StreamingResponse: + job = self.jobs.get(index_job_id) + if not job: + raise AppError("not_found", f"Index job not found: {index_job_id}", ModuleName.RAG) + queue = await self._events.subscribe(index_job_id, replay=True) + + async def event_stream(): + import asyncio + + heartbeat = 10 + try: + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=heartbeat) + yield EventBus.as_sse(event) + if event.name == "terminal": + break + except asyncio.TimeoutError: + yield ": keepalive\n\n" + finally: + await self._events.unsubscribe(index_job_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", + }, + ) + + router.include_router(legacy) + return router + + def internal_router(self) -> APIRouter: + router = APIRouter(prefix="/internal/rag", tags=["internal-rag"]) + + @router.post("/index/snapshot") + async def index_snapshot(request: IndexSnapshotRequest) -> dict: + rag_session_id = request.project_id + if not self.sessions.get(rag_session_id): + self.sessions.put(rag_session_id=rag_session_id, project_id=rag_session_id) + indexed, failed = await self.rag.index_snapshot( + rag_session_id=rag_session_id, + files=[x.model_dump() for x in request.files], + ) + return {"indexed_files": indexed, "failed_files": failed} + + @router.post("/index/changes") + async def index_changes(request: IndexChangesRequest) -> dict: + rag_session_id = request.project_id + indexed, failed = await self.rag.index_changes( + rag_session_id=rag_session_id, + changed_files=[x.model_dump() for x in request.changed_files], + ) + return {"indexed_files": indexed, "failed_files": failed} + + @router.get("/index/jobs/{index_job_id}") + async def get_job(index_job_id: str) -> dict: + job = self.jobs.get(index_job_id) + if not job: + return {"status": "not_found"} + return { + "index_job_id": job.index_job_id, + "status": job.status.value, + "indexed_files": job.indexed_files, + "failed_files": job.failed_files, + "error": job.error.model_dump(mode="json") if job.error else None, + } + + @router.post("/retrieve") + async def retrieve(payload: dict) -> dict: + rag_session_id = payload.get("rag_session_id") or payload.get("project_id", "") + ctx = await self.rag.retrieve( + rag_session_id=rag_session_id, + query=payload.get("query", ""), + ) + return {"items": ctx} + + return router diff --git a/app/modules/rag/repository.py b/app/modules/rag/repository.py new file mode 100644 index 0000000..b34c5fe --- /dev/null +++ b/app/modules/rag/repository.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +from dataclasses import dataclass +from sqlalchemy import text + +from app.modules.shared.db import get_engine + + +@dataclass +class RagJobRow: + index_job_id: str + rag_session_id: str + status: str + indexed_files: int + failed_files: int + error_code: str | None + error_desc: str | None + error_module: str | None + + +class RagRepository: + def ensure_tables(self) -> None: + engine = get_engine() + with engine.connect() as conn: + conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS rag_sessions ( + rag_session_id VARCHAR(64) PRIMARY KEY, + project_id VARCHAR(512) NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS rag_index_jobs ( + index_job_id VARCHAR(64) PRIMARY KEY, + rag_session_id VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + indexed_files INTEGER NOT NULL DEFAULT 0, + failed_files INTEGER NOT NULL DEFAULT 0, + error_code VARCHAR(128) NULL, + error_desc TEXT NULL, + error_module VARCHAR(64) NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS rag_chunks ( + id BIGSERIAL PRIMARY KEY, + rag_session_id VARCHAR(64) NOT NULL, + path TEXT NOT NULL, + chunk_index INTEGER NOT NULL, + content TEXT NOT NULL, + embedding vector NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + ALTER TABLE rag_chunks + ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + """ + ) + ) + conn.execute( + text( + """ + ALTER TABLE rag_chunks + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + """ + ) + ) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_rag_chunks_session ON rag_chunks (rag_session_id)")) + conn.commit() + + def upsert_session(self, rag_session_id: str, project_id: str) -> None: + with get_engine().connect() as conn: + conn.execute( + text( + """ + INSERT INTO rag_sessions (rag_session_id, project_id) + VALUES (:sid, :pid) + ON CONFLICT (rag_session_id) DO UPDATE SET project_id = EXCLUDED.project_id + """ + ), + {"sid": rag_session_id, "pid": project_id}, + ) + conn.commit() + + def session_exists(self, rag_session_id: str) -> bool: + with get_engine().connect() as conn: + row = conn.execute( + text("SELECT 1 FROM rag_sessions WHERE rag_session_id = :sid"), + {"sid": rag_session_id}, + ).fetchone() + return bool(row) + + def get_session(self, rag_session_id: str) -> dict | None: + with get_engine().connect() as conn: + row = conn.execute( + text("SELECT rag_session_id, project_id FROM rag_sessions WHERE rag_session_id = :sid"), + {"sid": rag_session_id}, + ).mappings().fetchone() + return dict(row) if row else None + + def create_job(self, index_job_id: str, rag_session_id: str, status: str) -> None: + with get_engine().connect() as conn: + conn.execute( + text( + """ + INSERT INTO rag_index_jobs (index_job_id, rag_session_id, status) + VALUES (:jid, :sid, :status) + """ + ), + {"jid": index_job_id, "sid": rag_session_id, "status": status}, + ) + conn.commit() + + def update_job( + self, + index_job_id: str, + *, + status: str, + indexed_files: int, + failed_files: int, + error_code: str | None = None, + error_desc: str | None = None, + error_module: str | None = None, + ) -> None: + with get_engine().connect() as conn: + conn.execute( + text( + """ + UPDATE rag_index_jobs + SET status = :status, + indexed_files = :indexed, + failed_files = :failed, + error_code = :ecode, + error_desc = :edesc, + error_module = :emodule, + updated_at = CURRENT_TIMESTAMP + WHERE index_job_id = :jid + """ + ), + { + "jid": index_job_id, + "status": status, + "indexed": indexed_files, + "failed": failed_files, + "ecode": error_code, + "edesc": error_desc, + "emodule": error_module, + }, + ) + conn.commit() + + def get_job(self, index_job_id: str) -> RagJobRow | None: + with get_engine().connect() as conn: + row = conn.execute( + text( + """ + SELECT index_job_id, rag_session_id, status, indexed_files, failed_files, + error_code, error_desc, error_module + FROM rag_index_jobs + WHERE index_job_id = :jid + """ + ), + {"jid": index_job_id}, + ).mappings().fetchone() + if not row: + return None + return RagJobRow(**dict(row)) + + def replace_chunks(self, rag_session_id: str, items: list[dict]) -> None: + with get_engine().connect() as conn: + conn.execute(text("DELETE FROM rag_chunks WHERE rag_session_id = :sid"), {"sid": rag_session_id}) + self._insert_chunks(conn, rag_session_id, items) + conn.commit() + + def apply_changes(self, rag_session_id: str, delete_paths: list[str], upserts: list[dict]) -> None: + with get_engine().connect() as conn: + if delete_paths: + conn.execute( + text("DELETE FROM rag_chunks WHERE rag_session_id = :sid AND path = ANY(:paths)"), + {"sid": rag_session_id, "paths": delete_paths}, + ) + if upserts: + paths = sorted({str(x["path"]) for x in upserts}) + conn.execute( + text("DELETE FROM rag_chunks WHERE rag_session_id = :sid AND path = ANY(:paths)"), + {"sid": rag_session_id, "paths": paths}, + ) + self._insert_chunks(conn, rag_session_id, upserts) + conn.commit() + + def retrieve(self, rag_session_id: str, query_embedding: list[float], limit: int = 5) -> list[dict]: + emb = "[" + ",".join(str(x) for x in query_embedding) + "]" + with get_engine().connect() as conn: + rows = conn.execute( + text( + """ + SELECT path, content + FROM rag_chunks + WHERE rag_session_id = :sid + ORDER BY embedding <=> CAST(:emb AS vector) + LIMIT :lim + """ + ), + {"sid": rag_session_id, "emb": emb, "lim": limit}, + ).mappings().fetchall() + return [dict(x) for x in rows] + + def fallback_chunks(self, rag_session_id: str, limit: int = 5) -> list[dict]: + with get_engine().connect() as conn: + rows = conn.execute( + text( + """ + SELECT path, content + FROM rag_chunks + WHERE rag_session_id = :sid + ORDER BY id DESC + LIMIT :lim + """ + ), + {"sid": rag_session_id, "lim": limit}, + ).mappings().fetchall() + return [dict(x) for x in rows] + + def _insert_chunks(self, conn, rag_session_id: str, items: list[dict]) -> None: + for item in items: + emb = item.get("embedding") or [] + emb_str = "[" + ",".join(str(x) for x in emb) + "]" if emb else None + conn.execute( + text( + """ + INSERT INTO rag_chunks (rag_session_id, path, chunk_index, content, embedding, created_at, updated_at) + VALUES (:sid, :path, :idx, :content, CAST(:emb AS vector), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """ + ), + { + "sid": rag_session_id, + "path": item["path"], + "idx": int(item["chunk_index"]), + "content": item["content"], + "emb": emb_str, + }, + ) diff --git a/app/modules/rag/retrieval/__init__.py b/app/modules/rag/retrieval/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/rag/retrieval/__pycache__/__init__.cpython-312.pyc b/app/modules/rag/retrieval/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..6a3d342 Binary files /dev/null and b/app/modules/rag/retrieval/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/rag/retrieval/__pycache__/chunker.cpython-312.pyc b/app/modules/rag/retrieval/__pycache__/chunker.cpython-312.pyc new file mode 100644 index 0000000..debcdce Binary files /dev/null and b/app/modules/rag/retrieval/__pycache__/chunker.cpython-312.pyc differ diff --git a/app/modules/rag/retrieval/__pycache__/scoring.cpython-312.pyc b/app/modules/rag/retrieval/__pycache__/scoring.cpython-312.pyc new file mode 100644 index 0000000..33ad645 Binary files /dev/null and b/app/modules/rag/retrieval/__pycache__/scoring.cpython-312.pyc differ diff --git a/app/modules/rag/retrieval/chunker.py b/app/modules/rag/retrieval/chunker.py new file mode 100644 index 0000000..410b1e4 --- /dev/null +++ b/app/modules/rag/retrieval/chunker.py @@ -0,0 +1,20 @@ +class TextChunker: + def __init__(self, chunk_size: int = 900, overlap: int = 120) -> None: + self._chunk_size = chunk_size + self._overlap = overlap + + def chunk(self, text: str) -> list[str]: + cleaned = text.replace("\r\n", "\n") + if not cleaned.strip(): + return [] + chunks: list[str] = [] + start = 0 + while start < len(cleaned): + end = min(len(cleaned), start + self._chunk_size) + piece = cleaned[start:end].strip() + if piece: + chunks.append(piece) + if end == len(cleaned): + break + start = max(0, end - self._overlap) + return chunks diff --git a/app/modules/rag/retrieval/scoring.py b/app/modules/rag/retrieval/scoring.py new file mode 100644 index 0000000..77d421f --- /dev/null +++ b/app/modules/rag/retrieval/scoring.py @@ -0,0 +1,12 @@ +import math + + +def cosine_similarity(a: list[float], b: list[float]) -> float: + if not a or not b or len(a) != len(b): + return -1.0 + dot = sum(x * y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(y * y for y in b)) + if norm_a == 0 or norm_b == 0: + return -1.0 + return dot / (norm_a * norm_b) diff --git a/app/modules/rag/service.py b/app/modules/rag/service.py new file mode 100644 index 0000000..51753c3 --- /dev/null +++ b/app/modules/rag/service.py @@ -0,0 +1,134 @@ +import asyncio +import os +from collections.abc import Awaitable, Callable +from inspect import isawaitable + +from app.modules.rag.embedding.gigachat_embedder import GigaChatEmbedder +from app.modules.rag.repository import RagRepository +from app.modules.rag.retrieval.chunker import TextChunker + + +class RagService: + def __init__( + self, + embedder: GigaChatEmbedder, + repository: RagRepository, + chunker: TextChunker | None = None, + ) -> None: + self._embedder = embedder + self._repo = repository + self._chunker = chunker or TextChunker() + + async def index_snapshot( + self, + rag_session_id: str, + files: list[dict], + progress_cb: Callable[[int, int, str], Awaitable[None] | None] | None = None, + ) -> tuple[int, int]: + total_files = len(files) + indexed_files = 0 + failed_files = 0 + all_chunks: list[dict] = [] + for index, file in enumerate(files, start=1): + path = str(file.get("path", "")) + try: + chunks = self._build_chunks_for_file(file) + embedded_chunks = await asyncio.to_thread(self._embed_chunks, chunks) + all_chunks.extend(embedded_chunks) + indexed_files += 1 + except Exception: + failed_files += 1 + await self._notify_progress(progress_cb, index, total_files, path) + await asyncio.to_thread(self._repo.replace_chunks, rag_session_id, all_chunks) + return indexed_files, failed_files + + async def index_changes( + self, + rag_session_id: str, + changed_files: list[dict], + progress_cb: Callable[[int, int, str], Awaitable[None] | None] | None = None, + ) -> tuple[int, int]: + total_files = len(changed_files) + indexed_files = 0 + failed_files = 0 + delete_paths: list[str] = [] + upsert_chunks: list[dict] = [] + + for index, file in enumerate(changed_files, start=1): + path = str(file.get("path", "")) + op = str(file.get("op", "")) + try: + if op == "delete": + delete_paths.append(path) + indexed_files += 1 + await self._notify_progress(progress_cb, index, total_files, path) + continue + if op == "upsert" and file.get("content") is not None: + chunks = self._build_chunks_for_file(file) + embedded_chunks = await asyncio.to_thread(self._embed_chunks, chunks) + upsert_chunks.extend(embedded_chunks) + indexed_files += 1 + await self._notify_progress(progress_cb, index, total_files, path) + continue + failed_files += 1 + except Exception: + failed_files += 1 + await self._notify_progress(progress_cb, index, total_files, path) + + await asyncio.to_thread( + self._repo.apply_changes, + rag_session_id, + delete_paths, + upsert_chunks, + ) + return indexed_files, failed_files + + async def retrieve(self, rag_session_id: str, query: str) -> list[dict]: + try: + query_embedding = self._embedder.embed([query])[0] + rows = self._repo.retrieve(rag_session_id, query_embedding, limit=5) + except Exception: + rows = self._repo.fallback_chunks(rag_session_id, limit=5) + return [{"source": row["path"], "content": row["content"]} for row in rows] + + def _build_chunks_for_file(self, file: dict) -> list[tuple[str, int, str]]: + path = str(file.get("path", "")) + content = str(file.get("content", "")) + output: list[tuple[str, int, str]] = [] + for idx, chunk in enumerate(self._chunker.chunk(content)): + output.append((path, idx, chunk)) + return output + + def _embed_chunks(self, raw_chunks: list[tuple[str, int, str]]) -> list[dict]: + if not raw_chunks: + return [] + batch_size = max(1, int(os.getenv("RAG_EMBED_BATCH_SIZE", "16"))) + + indexed: list[dict] = [] + for i in range(0, len(raw_chunks), batch_size): + batch = raw_chunks[i : i + batch_size] + texts = [x[2] for x in batch] + vectors = self._embedder.embed(texts) + for (path, chunk_index, content), vector in zip(batch, vectors): + indexed.append( + { + "path": path, + "chunk_index": chunk_index, + "content": content, + "embedding": vector, + } + ) + return indexed + + async def _notify_progress( + self, + progress_cb: Callable[[int, int, str], Awaitable[None] | None] | None, + current_file_index: int, + total_files: int, + current_file_name: str, + ) -> None: + if not progress_cb: + return + result = progress_cb(current_file_index, total_files, current_file_name) + if isawaitable(result): + await result diff --git a/app/modules/rag/session_store.py b/app/modules/rag/session_store.py new file mode 100644 index 0000000..e513598 --- /dev/null +++ b/app/modules/rag/session_store.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from uuid import uuid4 + +from app.modules.rag.repository import RagRepository + + +@dataclass +class RagSession: + rag_session_id: str + project_id: str + + +class RagSessionStore: + def __init__(self, repository: RagRepository) -> None: + self._repo = repository + + def create(self, project_id: str) -> RagSession: + session = RagSession(rag_session_id=str(uuid4()), project_id=project_id) + self._repo.upsert_session(session.rag_session_id, session.project_id) + return session + + def put(self, rag_session_id: str, project_id: str) -> RagSession: + session = RagSession(rag_session_id=rag_session_id, project_id=project_id) + self._repo.upsert_session(rag_session_id, project_id) + return session + + def get(self, rag_session_id: str) -> RagSession | None: + row = self._repo.get_session(rag_session_id) + if not row: + return None + return RagSession( + rag_session_id=str(row["rag_session_id"]), + project_id=str(row["project_id"]), + ) diff --git a/app/modules/shared/__init__.py b/app/modules/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/shared/__pycache__/__init__.cpython-312.pyc b/app/modules/shared/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..03916cf Binary files /dev/null and b/app/modules/shared/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/shared/__pycache__/bootstrap.cpython-312.pyc b/app/modules/shared/__pycache__/bootstrap.cpython-312.pyc new file mode 100644 index 0000000..52f9c1c Binary files /dev/null and b/app/modules/shared/__pycache__/bootstrap.cpython-312.pyc differ diff --git a/app/modules/shared/__pycache__/checkpointer.cpython-312.pyc b/app/modules/shared/__pycache__/checkpointer.cpython-312.pyc new file mode 100644 index 0000000..db4d8b6 Binary files /dev/null and b/app/modules/shared/__pycache__/checkpointer.cpython-312.pyc differ diff --git a/app/modules/shared/__pycache__/db.cpython-312.pyc b/app/modules/shared/__pycache__/db.cpython-312.pyc new file mode 100644 index 0000000..16c2bdc Binary files /dev/null and b/app/modules/shared/__pycache__/db.cpython-312.pyc differ diff --git a/app/modules/shared/__pycache__/event_bus.cpython-312.pyc b/app/modules/shared/__pycache__/event_bus.cpython-312.pyc new file mode 100644 index 0000000..40d33e5 Binary files /dev/null and b/app/modules/shared/__pycache__/event_bus.cpython-312.pyc differ diff --git a/app/modules/shared/__pycache__/idempotency_store.cpython-312.pyc b/app/modules/shared/__pycache__/idempotency_store.cpython-312.pyc new file mode 100644 index 0000000..b4d7b6f Binary files /dev/null and b/app/modules/shared/__pycache__/idempotency_store.cpython-312.pyc differ diff --git a/app/modules/shared/__pycache__/retry_executor.cpython-312.pyc b/app/modules/shared/__pycache__/retry_executor.cpython-312.pyc new file mode 100644 index 0000000..79d096b Binary files /dev/null and b/app/modules/shared/__pycache__/retry_executor.cpython-312.pyc differ diff --git a/app/modules/shared/bootstrap.py b/app/modules/shared/bootstrap.py new file mode 100644 index 0000000..f13f70b --- /dev/null +++ b/app/modules/shared/bootstrap.py @@ -0,0 +1,21 @@ +import time + +from app.modules.shared.checkpointer import get_checkpointer + + +def bootstrap_database(rag_repository, chat_repository, agent_repository) -> None: + last_error: Exception | None = None + for attempt in range(1, 16): + try: + rag_repository.ensure_tables() + chat_repository.ensure_tables() + agent_repository.ensure_tables() + get_checkpointer() + return + except Exception as exc: # noqa: BLE001 + last_error = exc + if attempt == 15: + break + time.sleep(1) + assert last_error is not None + raise last_error diff --git a/app/modules/shared/checkpointer.py b/app/modules/shared/checkpointer.py new file mode 100644 index 0000000..d47c93f --- /dev/null +++ b/app/modules/shared/checkpointer.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import threading + +import psycopg +from langgraph.checkpoint.postgres import PostgresSaver +from psycopg.rows import dict_row + +from app.modules.shared.db import database_url + +_CHECKPOINTER: PostgresSaver | None = None +_LOCK = threading.Lock() + + +def _conn_string() -> str: + url = database_url() + if url.startswith("postgresql+psycopg"): + return url.replace("postgresql+psycopg", "postgresql", 1) + return url + + +def get_checkpointer() -> PostgresSaver: + global _CHECKPOINTER + with _LOCK: + if _CHECKPOINTER is None: + conn = psycopg.connect(_conn_string(), autocommit=True, row_factory=dict_row) + cp = PostgresSaver(conn) + cp.setup() + _CHECKPOINTER = cp + return _CHECKPOINTER diff --git a/app/modules/shared/db.py b/app/modules/shared/db.py new file mode 100644 index 0000000..1e594ab --- /dev/null +++ b/app/modules/shared/db.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import os + +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool + +_ENGINE: Engine | None = None +_SESSION_FACTORY: sessionmaker | None = None + + +def database_url() -> str: + return os.getenv("DATABASE_URL", "postgresql+psycopg://agent:agent@db:5432/agent") + + +def get_engine() -> Engine: + global _ENGINE + if _ENGINE is None: + _ENGINE = create_engine(database_url(), poolclass=NullPool, future=True) + return _ENGINE + + +def get_session_factory() -> sessionmaker: + global _SESSION_FACTORY + if _SESSION_FACTORY is None: + _SESSION_FACTORY = sessionmaker(bind=get_engine(), autoflush=False, autocommit=False) + return _SESSION_FACTORY diff --git a/app/modules/shared/event_bus.py b/app/modules/shared/event_bus.py new file mode 100644 index 0000000..f00769b --- /dev/null +++ b/app/modules/shared/event_bus.py @@ -0,0 +1,57 @@ +import asyncio +import json +import time +from collections import defaultdict +from dataclasses import dataclass + + +@dataclass +class Event: + name: str + payload: dict + + +class EventBus: + def __init__(self) -> None: + self._channels: dict[str, list[asyncio.Queue[Event]]] = defaultdict(list) + self._history: dict[str, list[Event]] = defaultdict(list) + self._lock = asyncio.Lock() + self._history_limit = 5000 + + async def subscribe(self, channel_id: str, replay: bool = True) -> asyncio.Queue[Event]: + queue: asyncio.Queue[Event] = asyncio.Queue() + snapshot: list[Event] = [] + async with self._lock: + self._channels[channel_id].append(queue) + if replay: + snapshot = list(self._history.get(channel_id, [])) + for event in snapshot: + await queue.put(event) + return queue + + async def unsubscribe(self, channel_id: str, queue: asyncio.Queue[Event]) -> None: + async with self._lock: + if channel_id not in self._channels: + return + items = self._channels[channel_id] + if queue in items: + items.remove(queue) + if not items: + del self._channels[channel_id] + + async def publish(self, channel_id: str, name: str, payload: dict) -> None: + event_payload = dict(payload) + event_payload.setdefault("published_at_ms", int(time.time() * 1000)) + event = Event(name=name, payload=event_payload) + async with self._lock: + queues = list(self._channels.get(channel_id, [])) + history = self._history[channel_id] + history.append(event) + if len(history) > self._history_limit: + del history[: len(history) - self._history_limit] + for queue in queues: + await queue.put(event) + + @staticmethod + def as_sse(event: Event) -> str: + return f"event: {event.name}\ndata: {json.dumps(event.payload, ensure_ascii=False)}\n\n" diff --git a/app/modules/shared/gigachat/__init__.py b/app/modules/shared/gigachat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/shared/gigachat/__pycache__/__init__.cpython-312.pyc b/app/modules/shared/gigachat/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..2d36324 Binary files /dev/null and b/app/modules/shared/gigachat/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/modules/shared/gigachat/__pycache__/client.cpython-312.pyc b/app/modules/shared/gigachat/__pycache__/client.cpython-312.pyc new file mode 100644 index 0000000..6a0c283 Binary files /dev/null and b/app/modules/shared/gigachat/__pycache__/client.cpython-312.pyc differ diff --git a/app/modules/shared/gigachat/__pycache__/errors.cpython-312.pyc b/app/modules/shared/gigachat/__pycache__/errors.cpython-312.pyc new file mode 100644 index 0000000..b777f71 Binary files /dev/null and b/app/modules/shared/gigachat/__pycache__/errors.cpython-312.pyc differ diff --git a/app/modules/shared/gigachat/__pycache__/settings.cpython-312.pyc b/app/modules/shared/gigachat/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000..06510f8 Binary files /dev/null and b/app/modules/shared/gigachat/__pycache__/settings.cpython-312.pyc differ diff --git a/app/modules/shared/gigachat/__pycache__/token_provider.cpython-312.pyc b/app/modules/shared/gigachat/__pycache__/token_provider.cpython-312.pyc new file mode 100644 index 0000000..167eaa9 Binary files /dev/null and b/app/modules/shared/gigachat/__pycache__/token_provider.cpython-312.pyc differ diff --git a/app/modules/shared/gigachat/client.py b/app/modules/shared/gigachat/client.py new file mode 100644 index 0000000..2b00def --- /dev/null +++ b/app/modules/shared/gigachat/client.py @@ -0,0 +1,73 @@ +import requests + +from app.modules.shared.gigachat.errors import GigaChatError +from app.modules.shared.gigachat.settings import GigaChatSettings +from app.modules.shared.gigachat.token_provider import GigaChatTokenProvider + + +class GigaChatClient: + def __init__(self, settings: GigaChatSettings, token_provider: GigaChatTokenProvider) -> None: + self._settings = settings + self._tokens = token_provider + + def complete(self, system_prompt: str, user_prompt: str) -> str: + token = self._tokens.get_access_token() + payload = { + "model": self._settings.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + } + try: + response = requests.post( + f"{self._settings.api_url.rstrip('/')}/chat/completions", + json=payload, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + timeout=90, + verify=self._settings.ssl_verify, + ) + except requests.RequestException as exc: + raise GigaChatError(f"GigaChat completion request failed: {exc}") from exc + + if response.status_code >= 400: + raise GigaChatError(f"GigaChat completion error {response.status_code}: {response.text}") + + data = response.json() + choices = data.get("choices") or [] + if not choices: + return "" + message = choices[0].get("message") or {} + return str(message.get("content") or "") + + def embed(self, texts: list[str]) -> list[list[float]]: + token = self._tokens.get_access_token() + payload = { + "model": self._settings.embedding_model, + "input": texts, + } + try: + response = requests.post( + f"{self._settings.api_url.rstrip('/')}/embeddings", + json=payload, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + timeout=90, + verify=self._settings.ssl_verify, + ) + except requests.RequestException as exc: + raise GigaChatError(f"GigaChat embeddings request failed: {exc}") from exc + + if response.status_code >= 400: + raise GigaChatError(f"GigaChat embeddings error {response.status_code}: {response.text}") + + data = response.json() + items = data.get("data") + if not isinstance(items, list): + raise GigaChatError("Unexpected GigaChat embeddings response") + return [list(map(float, x.get("embedding") or [])) for x in items] diff --git a/app/modules/shared/gigachat/errors.py b/app/modules/shared/gigachat/errors.py new file mode 100644 index 0000000..8e90ea6 --- /dev/null +++ b/app/modules/shared/gigachat/errors.py @@ -0,0 +1,2 @@ +class GigaChatError(OSError): + pass diff --git a/app/modules/shared/gigachat/settings.py b/app/modules/shared/gigachat/settings.py new file mode 100644 index 0000000..026515c --- /dev/null +++ b/app/modules/shared/gigachat/settings.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +import os + + +@dataclass(frozen=True) +class GigaChatSettings: + auth_url: str + api_url: str + scope: str + credentials: str + ssl_verify: bool + model: str + embedding_model: str + + @classmethod + def from_env(cls) -> "GigaChatSettings": + return cls( + auth_url=os.getenv("GIGACHAT_AUTH_URL", "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"), + api_url=os.getenv("GIGACHAT_API_URL", "https://gigachat.devices.sberbank.ru/api/v1"), + scope=os.getenv("GIGACHAT_SCOPE", "GIGACHAT_API_PERS"), + credentials=os.getenv("GIGACHAT_TOKEN", "").strip(), + ssl_verify=os.getenv("GIGACHAT_SSL_VERIFY", "true").lower() in {"1", "true", "yes"}, + model=os.getenv("GIGACHAT_MODEL", "GigaChat"), + embedding_model=os.getenv("GIGACHAT_EMBEDDING_MODEL", "Embeddings"), + ) diff --git a/app/modules/shared/gigachat/token_provider.py b/app/modules/shared/gigachat/token_provider.py new file mode 100644 index 0000000..7fb790f --- /dev/null +++ b/app/modules/shared/gigachat/token_provider.py @@ -0,0 +1,58 @@ +import threading +import time +import uuid + +import requests + +from app.modules.shared.gigachat.errors import GigaChatError +from app.modules.shared.gigachat.settings import GigaChatSettings + + +class GigaChatTokenProvider: + def __init__(self, settings: GigaChatSettings) -> None: + self._settings = settings + self._lock = threading.Lock() + self._token: str | None = None + self._expires_at_ms: float = 0 + + def get_access_token(self) -> str: + now_ms = time.time() * 1000 + with self._lock: + if self._token and self._expires_at_ms - 300_000 > now_ms: + return self._token + + token, expires_at = self._fetch_token() + with self._lock: + self._token = token + self._expires_at_ms = expires_at + return token + + def _fetch_token(self) -> tuple[str, float]: + if not self._settings.credentials: + raise GigaChatError("GIGACHAT_TOKEN is not set") + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "Authorization": f"Basic {self._settings.credentials}", + "RqUID": str(uuid.uuid4()), + } + try: + response = requests.post( + self._settings.auth_url, + headers=headers, + data=f"scope={self._settings.scope}", + timeout=30, + verify=self._settings.ssl_verify, + ) + except requests.RequestException as exc: + raise GigaChatError(f"GigaChat auth request failed: {exc}") from exc + + if response.status_code >= 400: + raise GigaChatError(f"GigaChat auth error {response.status_code}: {response.text}") + + payload = response.json() + token = payload.get("access_token") + expires_at = float(payload.get("expires_at", 0)) + if not token: + raise GigaChatError("GigaChat auth: no access_token in response") + return token, expires_at diff --git a/app/modules/shared/idempotency_store.py b/app/modules/shared/idempotency_store.py new file mode 100644 index 0000000..5434988 --- /dev/null +++ b/app/modules/shared/idempotency_store.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass +from datetime import datetime, timezone +from threading import Lock + +from app.core.constants import IDEMPOTENCY_TTL + + +@dataclass +class IdempotencyRecord: + task_id: str + created_at: datetime + + +class IdempotencyStore: + def __init__(self) -> None: + self._records: dict[str, IdempotencyRecord] = {} + self._lock = Lock() + + def get_task_id(self, key: str) -> str | None: + now = datetime.now(timezone.utc) + with self._lock: + self._cleanup_locked(now) + record = self._records.get(key) + return record.task_id if record else None + + def put(self, key: str, task_id: str) -> None: + with self._lock: + self._records[key] = IdempotencyRecord( + task_id=task_id, + created_at=datetime.now(timezone.utc), + ) + + def _cleanup_locked(self, now: datetime) -> None: + expired = [ + key + for key, rec in self._records.items() + if now - rec.created_at > IDEMPOTENCY_TTL + ] + for key in expired: + del self._records[key] diff --git a/app/modules/shared/retry_executor.py b/app/modules/shared/retry_executor.py new file mode 100644 index 0000000..eb0e89b --- /dev/null +++ b/app/modules/shared/retry_executor.py @@ -0,0 +1,21 @@ +import asyncio +from typing import Awaitable, Callable, TypeVar + +from app.core.constants import MAX_RETRIES + +T = TypeVar("T") + + +class RetryExecutor: + async def run(self, operation: Callable[[], Awaitable[T]]) -> T: + last_error: Exception | None = None + for attempt in range(1, MAX_RETRIES + 1): + try: + return await operation() + except (TimeoutError, ConnectionError, OSError) as exc: + last_error = exc + if attempt == MAX_RETRIES: + break + await asyncio.sleep(0.1 * attempt) + assert last_error is not None + raise last_error diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/__pycache__/__init__.cpython-312.pyc b/app/schemas/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..f2bc482 Binary files /dev/null and b/app/schemas/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/schemas/__pycache__/changeset.cpython-312.pyc b/app/schemas/__pycache__/changeset.cpython-312.pyc new file mode 100644 index 0000000..83f1947 Binary files /dev/null and b/app/schemas/__pycache__/changeset.cpython-312.pyc differ diff --git a/app/schemas/__pycache__/chat.cpython-312.pyc b/app/schemas/__pycache__/chat.cpython-312.pyc new file mode 100644 index 0000000..38e8ac9 Binary files /dev/null and b/app/schemas/__pycache__/chat.cpython-312.pyc differ diff --git a/app/schemas/__pycache__/common.cpython-312.pyc b/app/schemas/__pycache__/common.cpython-312.pyc new file mode 100644 index 0000000..c1921f8 Binary files /dev/null and b/app/schemas/__pycache__/common.cpython-312.pyc differ diff --git a/app/schemas/__pycache__/indexing.cpython-312.pyc b/app/schemas/__pycache__/indexing.cpython-312.pyc new file mode 100644 index 0000000..5486f51 Binary files /dev/null and b/app/schemas/__pycache__/indexing.cpython-312.pyc differ diff --git a/app/schemas/__pycache__/rag_sessions.cpython-312.pyc b/app/schemas/__pycache__/rag_sessions.cpython-312.pyc new file mode 100644 index 0000000..11fa637 Binary files /dev/null and b/app/schemas/__pycache__/rag_sessions.cpython-312.pyc differ diff --git a/app/schemas/changeset.py b/app/schemas/changeset.py new file mode 100644 index 0000000..112835b --- /dev/null +++ b/app/schemas/changeset.py @@ -0,0 +1,34 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field, model_validator + + +class ChangeOp(str, Enum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + + +class ChangeItem(BaseModel): + op: ChangeOp + path: str = Field(min_length=1) + base_hash: Optional[str] = None + proposed_content: Optional[str] = None + reason: str = Field(min_length=1, max_length=500) + + @model_validator(mode="after") + def validate_op_fields(self) -> "ChangeItem": + if self.op in (ChangeOp.UPDATE, ChangeOp.DELETE) and not self.base_hash: + raise ValueError("base_hash is required for update/delete") + if self.op in (ChangeOp.CREATE, ChangeOp.UPDATE) and self.proposed_content is None: + raise ValueError("proposed_content is required for create/update") + if self.op == ChangeOp.DELETE and self.proposed_content is not None: + raise ValueError("proposed_content is forbidden for delete") + return self + + +class ChangeSetPayload(BaseModel): + schema_version: str + task_id: str + changeset: list[ChangeItem] diff --git a/app/schemas/chat.py b/app/schemas/chat.py new file mode 100644 index 0000000..bb007bc --- /dev/null +++ b/app/schemas/chat.py @@ -0,0 +1,80 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field, HttpUrl + +from app.schemas.changeset import ChangeItem +from app.schemas.common import ErrorPayload + + +class AttachmentType(str, Enum): + CONFLUENCE_URL = "confluence_url" + + +class ChatMode(str, Enum): + AUTO = "auto" + PROJECT_QA = "project_qa" + PROJECT_EDITS = "project_edits" + DOCS_GENERATION = "docs_generation" + # Legacy alias preserved for backward compatibility. + CODE_CHANGE = "code_change" + # Legacy alias preserved for backward compatibility. + ANALYTICS_REVIEW = "analytics_review" + QA = "qa" + + +class Attachment(BaseModel): + type: AttachmentType + url: HttpUrl + + +class ChatFileContext(BaseModel): + path: str = Field(min_length=1) + content: str + content_hash: str = Field(min_length=1) + + +class ChatMessageRequest(BaseModel): + mode: ChatMode = ChatMode.AUTO + dialog_session_id: str | None = Field(default=None, min_length=1) + rag_session_id: str | None = Field(default=None, min_length=1) + session_id: str | None = Field(default=None, min_length=1) + project_id: str | None = Field(default=None, min_length=1) + message: str = Field(min_length=1) + attachments: list[Attachment] = Field(default_factory=list) + files: list[ChatFileContext] = Field(default_factory=list) + + +class TaskQueuedResponse(BaseModel): + task_id: str + status: str + + +class TaskStatus(str, Enum): + QUEUED = "queued" + RUNNING = "running" + DONE = "done" + ERROR = "error" + + +class TaskResultType(str, Enum): + ANSWER = "answer" + CHANGESET = "changeset" + + +class TaskResultResponse(BaseModel): + task_id: str + status: TaskStatus + result_type: Optional[TaskResultType] = None + answer: Optional[str] = None + changeset: list[ChangeItem] = Field(default_factory=list) + error: Optional[ErrorPayload] = None + + +class DialogCreateRequest(BaseModel): + rag_session_id: str = Field(min_length=1) + + +class DialogCreateResponse(BaseModel): + dialog_session_id: str + rag_session_id: str diff --git a/app/schemas/common.py b/app/schemas/common.py new file mode 100644 index 0000000..a2d9ef1 --- /dev/null +++ b/app/schemas/common.py @@ -0,0 +1,17 @@ +from enum import Enum + +from pydantic import BaseModel + + +class ModuleName(str, Enum): + BACKEND = "backend" + AGENT = "agent" + RAG = "rag" + CONFLUENCE = "confluence" + FRONTEND = "frontend" + + +class ErrorPayload(BaseModel): + code: str + desc: str + module: ModuleName diff --git a/app/schemas/indexing.py b/app/schemas/indexing.py new file mode 100644 index 0000000..26e6a4e --- /dev/null +++ b/app/schemas/indexing.py @@ -0,0 +1,54 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + +from app.schemas.common import ErrorPayload + + +class FileSnapshot(BaseModel): + path: str = Field(min_length=1) + content: str + content_hash: str = Field(min_length=1) + + +class IndexSnapshotRequest(BaseModel): + project_id: str = Field(min_length=1) + files: list[FileSnapshot] + + +class ChangeOp(str, Enum): + UPSERT = "upsert" + DELETE = "delete" + + +class ChangedFile(BaseModel): + op: ChangeOp + path: str = Field(min_length=1) + content: Optional[str] = None + content_hash: Optional[str] = None + + +class IndexChangesRequest(BaseModel): + project_id: str = Field(min_length=1) + changed_files: list[ChangedFile] + + +class IndexJobQueuedResponse(BaseModel): + index_job_id: str + status: str + + +class IndexJobStatus(str, Enum): + QUEUED = "queued" + RUNNING = "running" + DONE = "done" + ERROR = "error" + + +class IndexJobResponse(BaseModel): + index_job_id: str + status: IndexJobStatus + indexed_files: int = 0 + failed_files: int = 0 + error: Optional[ErrorPayload] = None diff --git a/app/schemas/rag_sessions.py b/app/schemas/rag_sessions.py new file mode 100644 index 0000000..01a3bf7 --- /dev/null +++ b/app/schemas/rag_sessions.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel, Field + +from app.schemas.indexing import ChangedFile, FileSnapshot, IndexJobStatus + + +class RagSessionCreateRequest(BaseModel): + project_id: str = Field(min_length=1) + files: list[FileSnapshot] + + +class RagSessionCreateResponse(BaseModel): + rag_session_id: str + index_job_id: str + status: IndexJobStatus + + +class RagSessionChangesRequest(BaseModel): + changed_files: list[ChangedFile] + + +class RagSessionJobResponse(BaseModel): + rag_session_id: str + index_job_id: str + status: IndexJobStatus + indexed_files: int = 0 + failed_files: int = 0 + error: dict | None = None diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..979c31b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + db: + image: pgvector/pgvector:pg16 + container_name: agent-db + env_file: + - .env + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/postgres-init:/docker-entrypoint-initdb.d + ports: + - "${POSTGRES_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U agent -d agent"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: + context: . + dockerfile: Dockerfile + container_name: agent-backend + env_file: + - .env + environment: + DATABASE_URL: ${DATABASE_URL} + GIGACHAT_AUTH_URL: ${GIGACHAT_AUTH_URL} + GIGACHAT_API_URL: ${GIGACHAT_API_URL} + GIGACHAT_SCOPE: ${GIGACHAT_SCOPE} + GIGACHAT_TOKEN: ${GIGACHAT_TOKEN} + GIGACHAT_SSL_VERIFY: ${GIGACHAT_SSL_VERIFY} + GIGACHAT_MODEL: ${GIGACHAT_MODEL} + GIGACHAT_EMBEDDING_MODEL: ${GIGACHAT_EMBEDDING_MODEL} + AGENT_PROMPTS_DIR: ${AGENT_PROMPTS_DIR} + ports: + - "${BACKEND_PORT:-15000}:15000" + depends_on: + db: + condition: service_healthy + +volumes: + postgres_data: diff --git a/docker/postgres-init/01_pgvector.sql b/docker/postgres-init/01_pgvector.sql new file mode 100644 index 0000000..0aa0fc2 --- /dev/null +++ b/docker/postgres-init/01_pgvector.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS vector; diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..79e0566 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.116.1 +uvicorn==0.35.0 +pydantic==2.11.7 +langgraph==0.6.7 +langgraph-checkpoint-postgres==2.0.23 +PyYAML==6.0.2 +requests==2.32.3 +SQLAlchemy==2.0.43 +psycopg[binary]==3.2.9