From 43c404f95867c1469699b99236df6ba5767cbcb9 Mon Sep 17 00:00:00 2001 From: zosimovaa Date: Wed, 25 Feb 2026 14:47:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 8 + .env | 24 + .env.example | 24 + Dockerfile | 15 + README.md | 143 +++++ app/__init__.py | 0 app/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 112 bytes app/__pycache__/main.cpython-312.pyc | Bin 0 -> 1990 bytes app/core/__init__.py | 0 app/core/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 117 bytes .../__pycache__/constants.cpython-312.pyc | Bin 0 -> 292 bytes .../error_handlers.cpython-312.pyc | Bin 0 -> 2165 bytes .../__pycache__/exceptions.cpython-312.pyc | Bin 0 -> 761 bytes app/core/constants.py | 5 + app/core/error_handlers.py | 37 ++ app/core/exceptions.py | 9 + app/main.py | 38 ++ app/modules/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 120 bytes .../__pycache__/application.cpython-312.pyc | Bin 0 -> 2224 bytes .../__pycache__/contracts.cpython-312.pyc | Bin 0 -> 2655 bytes app/modules/agent/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 126 bytes .../changeset_validator.cpython-312.pyc | Bin 0 -> 1209 bytes .../confluence_service.cpython-312.pyc | Bin 0 -> 1385 bytes .../agent/__pycache__/module.cpython-312.pyc | Bin 0 -> 2920 bytes .../__pycache__/prompt_loader.cpython-312.pyc | Bin 0 -> 1366 bytes .../__pycache__/repository.cpython-312.pyc | Bin 0 -> 4919 bytes .../agent/__pycache__/service.cpython-312.pyc | Bin 0 -> 16481 bytes app/modules/agent/changeset_validator.py | 20 + app/modules/agent/confluence_service.py | 20 + app/modules/agent/engine/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 133 bytes app/modules/agent/engine/graphs/__init__.py | 11 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 552 bytes .../analytics_graph.cpython-312.pyc | Bin 0 -> 2398 bytes .../__pycache__/base_graph.cpython-312.pyc | Bin 0 -> 3155 bytes .../code_change_graph.cpython-312.pyc | Bin 0 -> 4383 bytes .../docs_examples_loader.cpython-312.pyc | Bin 0 -> 2505 bytes .../__pycache__/docs_graph.cpython-312.pyc | Bin 0 -> 9438 bytes .../docs_graph_logic.cpython-312.pyc | Bin 0 -> 31433 bytes .../file_targeting.cpython-312.pyc | Bin 0 -> 1732 bytes .../__pycache__/progress.cpython-312.pyc | Bin 0 -> 1929 bytes .../progress_registry.cpython-312.pyc | Bin 0 -> 1869 bytes .../project_edits_graph.cpython-312.pyc | Bin 0 -> 4941 bytes .../project_edits_logic.cpython-312.pyc | Bin 0 -> 18555 bytes .../project_qa_graph.cpython-312.pyc | Bin 0 -> 2349 bytes .../graphs/__pycache__/state.cpython-312.pyc | Bin 0 -> 1382 bytes app/modules/agent/engine/graphs/base_graph.py | 58 ++ .../engine/graphs/docs_examples_loader.py | 26 + app/modules/agent/engine/graphs/docs_graph.py | 128 +++++ .../agent/engine/graphs/docs_graph_logic.py | 519 ++++++++++++++++++ .../agent/engine/graphs/file_targeting.py | 28 + app/modules/agent/engine/graphs/progress.py | 44 ++ .../agent/engine/graphs/progress_registry.py | 27 + .../engine/graphs/project_edits_graph.py | 79 +++ .../engine/graphs/project_edits_logic.py | 271 +++++++++ .../agent/engine/graphs/project_qa_graph.py | 38 ++ app/modules/agent/engine/graphs/state.py | 32 ++ app/modules/agent/engine/router/__init__.py | 34 ++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1991 bytes .../__pycache__/context_store.cpython-312.pyc | Bin 0 -> 1470 bytes .../intent_classifier.cpython-312.pyc | Bin 0 -> 8159 bytes .../__pycache__/registry.cpython-312.pyc | Bin 0 -> 2865 bytes .../router_service.cpython-312.pyc | Bin 0 -> 3099 bytes .../__pycache__/schemas.cpython-312.pyc | Bin 0 -> 1730 bytes .../agent/engine/router/context_store.py | 29 + .../agent/engine/router/intent_classifier.py | 191 +++++++ .../agent/engine/router/intents_registry.yaml | 17 + app/modules/agent/engine/router/registry.py | 46 ++ .../agent/engine/router/router_service.py | 62 +++ app/modules/agent/engine/router/schemas.py | 27 + app/modules/agent/llm/__init__.py | 3 + .../llm/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 230 bytes .../llm/__pycache__/service.cpython-312.pyc | Bin 0 -> 1184 bytes app/modules/agent/llm/service.py | 14 + app/modules/agent/module.py | 44 ++ app/modules/agent/prompt_loader.py | 15 + app/modules/agent/prompts/docs_detect.txt | 18 + .../docs_examples/from_scratch_example.md | 27 + .../incremental_update_example.md | 21 + .../agent/prompts/docs_execution_summary.txt | 12 + app/modules/agent/prompts/docs_generation.txt | 53 ++ .../agent/prompts/docs_plan_sections.txt | 25 + app/modules/agent/prompts/docs_self_check.txt | 22 + app/modules/agent/prompts/docs_strategy.txt | 14 + app/modules/agent/prompts/general_answer.txt | 3 + app/modules/agent/prompts/project_answer.txt | 9 + .../agent/prompts/project_edits_apply.txt | 10 + .../agent/prompts/project_edits_plan.txt | 15 + .../prompts/project_edits_self_check.txt | 12 + app/modules/agent/prompts/router_intent.txt | 23 + app/modules/agent/repository.py | 106 ++++ app/modules/agent/service.py | 296 ++++++++++ app/modules/application.py | 31 ++ app/modules/chat/__init__.py | 0 .../chat/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 125 bytes .../__pycache__/dialog_store.cpython-312.pyc | Bin 0 -> 1790 bytes .../chat/__pycache__/module.cpython-312.pyc | Bin 0 -> 6513 bytes .../__pycache__/repository.cpython-312.pyc | Bin 0 -> 4445 bytes .../chat/__pycache__/service.cpython-312.pyc | Bin 0 -> 15649 bytes .../__pycache__/task_store.cpython-312.pyc | Bin 0 -> 2497 bytes app/modules/chat/dialog_store.py | 29 + app/modules/chat/module.py | 104 ++++ app/modules/chat/repository.py | 93 ++++ app/modules/chat/service.py | 276 ++++++++++ app/modules/chat/task_store.py | 37 ++ app/modules/contracts.py | 47 ++ app/modules/rag/__init__.py | 0 .../rag/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 124 bytes .../indexing_service.cpython-312.pyc | Bin 0 -> 7550 bytes .../rag/__pycache__/job_store.cpython-312.pyc | Bin 0 -> 3589 bytes .../rag/__pycache__/module.cpython-312.pyc | Bin 0 -> 15646 bytes .../__pycache__/repository.cpython-312.pyc | Bin 0 -> 13283 bytes .../rag/__pycache__/service.cpython-312.pyc | Bin 0 -> 7405 bytes .../__pycache__/session_store.cpython-312.pyc | Bin 0 -> 2067 bytes app/modules/rag/embedding/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 134 bytes .../gigachat_embedder.cpython-312.pyc | Bin 0 -> 888 bytes .../rag/embedding/gigachat_embedder.py | 9 + app/modules/rag/indexing_service.py | 141 +++++ app/modules/rag/job_store.py | 66 +++ app/modules/rag/module.py | 247 +++++++++ app/modules/rag/repository.py | 261 +++++++++ app/modules/rag/retrieval/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 134 bytes .../__pycache__/chunker.cpython-312.pyc | Bin 0 -> 1399 bytes .../__pycache__/scoring.cpython-312.pyc | Bin 0 -> 1363 bytes app/modules/rag/retrieval/chunker.py | 20 + app/modules/rag/retrieval/scoring.py | 12 + app/modules/rag/service.py | 134 +++++ app/modules/rag/session_store.py | 34 ++ app/modules/shared/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 127 bytes .../__pycache__/bootstrap.cpython-312.pyc | Bin 0 -> 933 bytes .../__pycache__/checkpointer.cpython-312.pyc | Bin 0 -> 1365 bytes .../shared/__pycache__/db.cpython-312.pyc | Bin 0 -> 1300 bytes .../__pycache__/event_bus.cpython-312.pyc | Bin 0 -> 4503 bytes .../idempotency_store.cpython-312.pyc | Bin 0 -> 2578 bytes .../retry_executor.cpython-312.pyc | Bin 0 -> 1296 bytes app/modules/shared/bootstrap.py | 21 + app/modules/shared/checkpointer.py | 30 + app/modules/shared/db.py | 29 + app/modules/shared/event_bus.py | 57 ++ app/modules/shared/gigachat/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 136 bytes .../__pycache__/client.cpython-312.pyc | Bin 0 -> 4173 bytes .../__pycache__/errors.cpython-312.pyc | Bin 0 -> 322 bytes .../__pycache__/settings.cpython-312.pyc | Bin 0 -> 1692 bytes .../token_provider.cpython-312.pyc | Bin 0 -> 3393 bytes app/modules/shared/gigachat/client.py | 73 +++ app/modules/shared/gigachat/errors.py | 2 + app/modules/shared/gigachat/settings.py | 25 + app/modules/shared/gigachat/token_provider.py | 58 ++ app/modules/shared/idempotency_store.py | 40 ++ app/modules/shared/retry_executor.py | 21 + app/schemas/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 120 bytes .../__pycache__/changeset.cpython-312.pyc | Bin 0 -> 2202 bytes app/schemas/__pycache__/chat.cpython-312.pyc | Bin 0 -> 3980 bytes .../__pycache__/common.cpython-312.pyc | Bin 0 -> 765 bytes .../__pycache__/indexing.cpython-312.pyc | Bin 0 -> 2698 bytes .../__pycache__/rag_sessions.cpython-312.pyc | Bin 0 -> 1530 bytes app/schemas/changeset.py | 34 ++ app/schemas/chat.py | 80 +++ app/schemas/common.py | 17 + app/schemas/indexing.py | 54 ++ app/schemas/rag_sessions.py | 27 + docker-compose.yml | 46 ++ docker/postgres-init/01_pgvector.sql | 1 + requirements.txt | 9 + 171 files changed, 4917 insertions(+) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/__pycache__/__init__.cpython-312.pyc create mode 100644 app/__pycache__/main.cpython-312.pyc create mode 100644 app/core/__init__.py create mode 100644 app/core/__pycache__/__init__.cpython-312.pyc create mode 100644 app/core/__pycache__/constants.cpython-312.pyc create mode 100644 app/core/__pycache__/error_handlers.cpython-312.pyc create mode 100644 app/core/__pycache__/exceptions.cpython-312.pyc create mode 100644 app/core/constants.py create mode 100644 app/core/error_handlers.py create mode 100644 app/core/exceptions.py create mode 100644 app/main.py create mode 100644 app/modules/__init__.py create mode 100644 app/modules/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/__pycache__/application.cpython-312.pyc create mode 100644 app/modules/__pycache__/contracts.cpython-312.pyc create mode 100644 app/modules/agent/__init__.py create mode 100644 app/modules/agent/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/agent/__pycache__/changeset_validator.cpython-312.pyc create mode 100644 app/modules/agent/__pycache__/confluence_service.cpython-312.pyc create mode 100644 app/modules/agent/__pycache__/module.cpython-312.pyc create mode 100644 app/modules/agent/__pycache__/prompt_loader.cpython-312.pyc create mode 100644 app/modules/agent/__pycache__/repository.cpython-312.pyc create mode 100644 app/modules/agent/__pycache__/service.cpython-312.pyc create mode 100644 app/modules/agent/changeset_validator.py create mode 100644 app/modules/agent/confluence_service.py create mode 100644 app/modules/agent/engine/__init__.py create mode 100644 app/modules/agent/engine/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__init__.py create mode 100644 app/modules/agent/engine/graphs/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/analytics_graph.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/base_graph.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/code_change_graph.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/docs_examples_loader.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/docs_graph.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/docs_graph_logic.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/file_targeting.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/progress.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/progress_registry.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/project_edits_graph.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/project_edits_logic.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/project_qa_graph.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/__pycache__/state.cpython-312.pyc create mode 100644 app/modules/agent/engine/graphs/base_graph.py create mode 100644 app/modules/agent/engine/graphs/docs_examples_loader.py create mode 100644 app/modules/agent/engine/graphs/docs_graph.py create mode 100644 app/modules/agent/engine/graphs/docs_graph_logic.py create mode 100644 app/modules/agent/engine/graphs/file_targeting.py create mode 100644 app/modules/agent/engine/graphs/progress.py create mode 100644 app/modules/agent/engine/graphs/progress_registry.py create mode 100644 app/modules/agent/engine/graphs/project_edits_graph.py create mode 100644 app/modules/agent/engine/graphs/project_edits_logic.py create mode 100644 app/modules/agent/engine/graphs/project_qa_graph.py create mode 100644 app/modules/agent/engine/graphs/state.py create mode 100644 app/modules/agent/engine/router/__init__.py create mode 100644 app/modules/agent/engine/router/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/agent/engine/router/__pycache__/context_store.cpython-312.pyc create mode 100644 app/modules/agent/engine/router/__pycache__/intent_classifier.cpython-312.pyc create mode 100644 app/modules/agent/engine/router/__pycache__/registry.cpython-312.pyc create mode 100644 app/modules/agent/engine/router/__pycache__/router_service.cpython-312.pyc create mode 100644 app/modules/agent/engine/router/__pycache__/schemas.cpython-312.pyc create mode 100644 app/modules/agent/engine/router/context_store.py create mode 100644 app/modules/agent/engine/router/intent_classifier.py create mode 100644 app/modules/agent/engine/router/intents_registry.yaml create mode 100644 app/modules/agent/engine/router/registry.py create mode 100644 app/modules/agent/engine/router/router_service.py create mode 100644 app/modules/agent/engine/router/schemas.py create mode 100644 app/modules/agent/llm/__init__.py create mode 100644 app/modules/agent/llm/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/agent/llm/__pycache__/service.cpython-312.pyc create mode 100644 app/modules/agent/llm/service.py create mode 100644 app/modules/agent/module.py create mode 100644 app/modules/agent/prompt_loader.py create mode 100644 app/modules/agent/prompts/docs_detect.txt create mode 100644 app/modules/agent/prompts/docs_examples/from_scratch_example.md create mode 100644 app/modules/agent/prompts/docs_examples/incremental_update_example.md create mode 100644 app/modules/agent/prompts/docs_execution_summary.txt create mode 100644 app/modules/agent/prompts/docs_generation.txt create mode 100644 app/modules/agent/prompts/docs_plan_sections.txt create mode 100644 app/modules/agent/prompts/docs_self_check.txt create mode 100644 app/modules/agent/prompts/docs_strategy.txt create mode 100644 app/modules/agent/prompts/general_answer.txt create mode 100644 app/modules/agent/prompts/project_answer.txt create mode 100644 app/modules/agent/prompts/project_edits_apply.txt create mode 100644 app/modules/agent/prompts/project_edits_plan.txt create mode 100644 app/modules/agent/prompts/project_edits_self_check.txt create mode 100644 app/modules/agent/prompts/router_intent.txt create mode 100644 app/modules/agent/repository.py create mode 100644 app/modules/agent/service.py create mode 100644 app/modules/application.py create mode 100644 app/modules/chat/__init__.py create mode 100644 app/modules/chat/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/chat/__pycache__/dialog_store.cpython-312.pyc create mode 100644 app/modules/chat/__pycache__/module.cpython-312.pyc create mode 100644 app/modules/chat/__pycache__/repository.cpython-312.pyc create mode 100644 app/modules/chat/__pycache__/service.cpython-312.pyc create mode 100644 app/modules/chat/__pycache__/task_store.cpython-312.pyc create mode 100644 app/modules/chat/dialog_store.py create mode 100644 app/modules/chat/module.py create mode 100644 app/modules/chat/repository.py create mode 100644 app/modules/chat/service.py create mode 100644 app/modules/chat/task_store.py create mode 100644 app/modules/contracts.py create mode 100644 app/modules/rag/__init__.py create mode 100644 app/modules/rag/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/rag/__pycache__/indexing_service.cpython-312.pyc create mode 100644 app/modules/rag/__pycache__/job_store.cpython-312.pyc create mode 100644 app/modules/rag/__pycache__/module.cpython-312.pyc create mode 100644 app/modules/rag/__pycache__/repository.cpython-312.pyc create mode 100644 app/modules/rag/__pycache__/service.cpython-312.pyc create mode 100644 app/modules/rag/__pycache__/session_store.cpython-312.pyc create mode 100644 app/modules/rag/embedding/__init__.py create mode 100644 app/modules/rag/embedding/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/rag/embedding/__pycache__/gigachat_embedder.cpython-312.pyc create mode 100644 app/modules/rag/embedding/gigachat_embedder.py create mode 100644 app/modules/rag/indexing_service.py create mode 100644 app/modules/rag/job_store.py create mode 100644 app/modules/rag/module.py create mode 100644 app/modules/rag/repository.py create mode 100644 app/modules/rag/retrieval/__init__.py create mode 100644 app/modules/rag/retrieval/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/rag/retrieval/__pycache__/chunker.cpython-312.pyc create mode 100644 app/modules/rag/retrieval/__pycache__/scoring.cpython-312.pyc create mode 100644 app/modules/rag/retrieval/chunker.py create mode 100644 app/modules/rag/retrieval/scoring.py create mode 100644 app/modules/rag/service.py create mode 100644 app/modules/rag/session_store.py create mode 100644 app/modules/shared/__init__.py create mode 100644 app/modules/shared/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/shared/__pycache__/bootstrap.cpython-312.pyc create mode 100644 app/modules/shared/__pycache__/checkpointer.cpython-312.pyc create mode 100644 app/modules/shared/__pycache__/db.cpython-312.pyc create mode 100644 app/modules/shared/__pycache__/event_bus.cpython-312.pyc create mode 100644 app/modules/shared/__pycache__/idempotency_store.cpython-312.pyc create mode 100644 app/modules/shared/__pycache__/retry_executor.cpython-312.pyc create mode 100644 app/modules/shared/bootstrap.py create mode 100644 app/modules/shared/checkpointer.py create mode 100644 app/modules/shared/db.py create mode 100644 app/modules/shared/event_bus.py create mode 100644 app/modules/shared/gigachat/__init__.py create mode 100644 app/modules/shared/gigachat/__pycache__/__init__.cpython-312.pyc create mode 100644 app/modules/shared/gigachat/__pycache__/client.cpython-312.pyc create mode 100644 app/modules/shared/gigachat/__pycache__/errors.cpython-312.pyc create mode 100644 app/modules/shared/gigachat/__pycache__/settings.cpython-312.pyc create mode 100644 app/modules/shared/gigachat/__pycache__/token_provider.cpython-312.pyc create mode 100644 app/modules/shared/gigachat/client.py create mode 100644 app/modules/shared/gigachat/errors.py create mode 100644 app/modules/shared/gigachat/settings.py create mode 100644 app/modules/shared/gigachat/token_provider.py create mode 100644 app/modules/shared/idempotency_store.py create mode 100644 app/modules/shared/retry_executor.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/__pycache__/__init__.cpython-312.pyc create mode 100644 app/schemas/__pycache__/changeset.cpython-312.pyc create mode 100644 app/schemas/__pycache__/chat.cpython-312.pyc create mode 100644 app/schemas/__pycache__/common.cpython-312.pyc create mode 100644 app/schemas/__pycache__/indexing.cpython-312.pyc create mode 100644 app/schemas/__pycache__/rag_sessions.cpython-312.pyc create mode 100644 app/schemas/changeset.py create mode 100644 app/schemas/chat.py create mode 100644 app/schemas/common.py create mode 100644 app/schemas/indexing.py create mode 100644 app/schemas/rag_sessions.py create mode 100644 docker-compose.yml create mode 100644 docker/postgres-init/01_pgvector.sql create mode 100644 requirements.txt 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 b/.env new file mode 100644 index 0000000..f325768 --- /dev/null +++ b/.env @@ -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=MGMyOGExMzctZDY1YS00OGNkLTk3NGYtYzFkZWVjOTEzM2RkOjFjOTc0YjFlLWNlMDUtNDM4Zi04ZDA2LWZkODA5MjRhZTY3NA== +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 +GIGACHAT_SSL_VERIFY=true +GIGACHAT_MODEL=GigaChat +GIGACHAT_EMBEDDING_MODEL=Embeddings +GIGACHAT_SSL_VERIFY=false + +# Optional +AGENT_PROMPTS_DIR= + + +RAG_EMBED_BATCH_SIZE=16 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 0000000000000000000000000000000000000000..1a7ca2379d964284197be91fcde7abfca2a28322 GIT binary patch literal 112 zcmX@j%ge<81fFTLGePuY5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!;!i9n(2tML%*!l^ lkJl@x{Ka9Do1apelWJGQ3Y2FA;$jfvBQql-V-Yiu1prXX6*B+; literal 0 HcmV?d00001 diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..009e082b3c382b4407087739bb1bea5e27c02df6 GIT binary patch literal 1990 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..890a2b8b7a64294cd9f9395b208e69832d1c5715 GIT binary patch literal 120 zcmX@j%ge<81ZrJ#GC}lX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!5>G5B(9g|JDa}bO p){l?R%*!l^kJl@x{Ka7d5w$B~1*&5N;$jfvBQql-V-Yiu1pq|i7-awe literal 0 HcmV?d00001 diff --git a/app/modules/__pycache__/application.cpython-312.pyc b/app/modules/__pycache__/application.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1f2b3b09802d86ceae1f6ffd16fde8faf015f870 GIT binary patch literal 2224 zcmb7FO>7%Q6rQ!$_WCbQ9P(p3N!_Ri>=Y*z6+!|b720ybQX+BLQmr=LC3U)Mo7pjn z+Z;ILkOLg53WW+J-~>@3l_Q+sh{Oe}sT_^$6I{3R3D`bGW42tvQHgIKZ$hI;@Gkbw-zLbjwz62l=YWQWx-$6-shBWi?W*^1gR zHO6toQtY@I=QwI5?4+9HIA*2nw3_Bvu_o+{n&CKZP1;lHl!QX)ZDb_&kdZtJxml>$ zPO6mtgB?l|OTElQWzI;LeR5v(|=)H8)9@dKshP0-O(!iB!oz zYRHg{(4MS@VGqMDccfB;W_d^*Uu(6jdQB(wW}{k@0^VYP!|)qs!R-MezU;_T+C`er zBTzzj1*FWHFA^6hAoVRsdW@7s=Zj1U6tMOEkr^S~Mcd-@1q*iIMgvC=0+%=6S;?_< z6duE}jHJQ_R#c9S1to!7Oqqi&@!dlm~S%s}a}5tXt@W8%wyx zENz;p!R!SBV)nM+X+{(#4}%eAWv3CI2WIU;W4PTQb=#zhNPx=B(V;|T$F$yeSmPI; z=k-==)#h)3vkF)YQl-_Qil)^Yb)sqa3g;)SB803BoMRv#q5kaB!|i?f<79tk`Jo0N z(O+8mytyx*D24uom!4c$WpJTiTzyi!!rEyNa%S)HIsV zG`9l)r!?(@wr;r+d=aehB_M~0d*GR2R&h>J033GOL&H!el6bZ(MQ%&3CSzAi({vu1 zs^T?Rh4-g-a$9!cym&NPywhnRsKQ#f642liGIvhH4d_k+YjHmVn3bS8pSsz9D|ps? zy3Qo%+|jXVRQx|UUPwAl#eY=XdA}G8;d$YYjb?iPg6>nmueQYQmlcd(_jBcSH=j4~ x%fMq}>s$u%XIPS?0a_cNR|n{o0jdnp)d9LZK(7tZ6`+4dr={D{Ux-oe?H`|){I382 literal 0 HcmV?d00001 diff --git a/app/modules/__pycache__/contracts.cpython-312.pyc b/app/modules/__pycache__/contracts.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7a5ed48194614208a9b0b5dea99ca76c1f9ee4f GIT binary patch literal 2655 zcmbtWO>7%Q6rS<^c)k8h6FW`w;}j@AhROlN0YQaS(t=tf~fi4ancg;Tgy;u5PIqLnI;5E9%BHd5OY@9oBkU8su6tmd2d-psz4 zeSfn*B$GOUR=WOW<&I3qFUa(ovP*QDKwKk?Flv)3H7G?oVn?cyAys8V4tU8{s;Z#| zv}{MKnxRn=A#W03R104st3{-NcBExJdnmgn&^`z8p(X>cT|_B zzBVsvUaeTO^ASIG{GwIytRUNWl_)cG+^u{FKM5z@-)~_xu8P>$bPpSmDL+ zSRwA7k#t@M{u<%Lpo|!ibA(G=X7m_Hxxyl=Q5a<=1-#0mhfppvIp8&*6r>uFyy~Zp zm$~CD)SYlgKQ4luO%Jx7^Hs}nFLL3>iopq8?)h?+dsaT?Yo_T~Rc@NTZkp8^tJ_E? zO!GqBvcnkx#~^;%G%d%ed6rkHIWDY`Tu=D2U4cQx1v0A?y-m^qn%o<+R#s+0b?!{D z=6J#?dTwE*A!0BcfChy7IKXFQ=fLzWd3q<2ZXUUE`j$MplOAcFZlwdEo?1ICMtd&} zm=UGeIv&8j85w1fFDR3^#AN6SbQO9OIv9;BXhs&>qjV0l!J{yWCNIl8%4o1E;7gHx zn#H+R8fJ+#)rd7{KIzAMhIhD_1GmRlM6C?Qcg^CGAN3#x%nI`lu!?2Z%BBkwU_KP3 zgf+m+U@h)PtFXcg3~f>&K%DOGIlig7r&uL|zfg;S)!U_&!TpnnRzlK^;& zP%uV=d?Q_?n{-ZOK?aVNU2d1e1TbO}VG1FKFpY2+;V}f<#YHb2?lo}`IA0QVXEojT zxdjjm;o>;JZ{*HLq&-T;r|xzj#mOfTndWto0#c+AaHSYV7(vJ&VD1ZC9Y)1dNa1lq zf8(wKO&2?e$ogA8M$B{~q^l&?gR0`Ln zks2bS z6v*bpf>mDNo~ZB-xClJuD;MfqGz8{Lu~$A&@dI5ij=*f_d`Ucsbl~x4fpSp+I5+gG z1_2f9w3_YTw$Kw}c(|D1;Np+TUGX%KbNT2)TzeE&-S5jh$`CN@+%bSPvLnxLoZXh^ zZ;SwJWwzM&<3Gytp*3;ofHg7Duyr;7_N^I%bRM)OBtFzjc%-t#wFf4%!eh89Oz@J% z)HTJ>_q0WOzIjA#8fc3{kY@EZ6b7fBfcHB4NvxBJT%Hv%H0_ZT=4<|24E1~`NTtl5bKLCRp9o)J$=Qiuh?8XkMF;Pa9^z{pbP=C z%>51kYK>0L&~x;Un4;u<9-C+@Fhb4Qtr=0|w7r_KUfXEg$ZUSNm1)hK+|HeBW#71& zX=UGT-; zul$Pz;45_k;d`L_fPh_K0E01s#i$HJF>A6Gr*aG=!RLxg^Ye2Hi}|}+aq514c2Zl;FBE6y3M3{?+IAkHDpFV+ zo?6o@t8m7J7EbuM2;F(TZdP@J)MEr8Q{j+9pI!xmrQA6^amrleLOdv+XK! zp`rO%TNjp2&t(5h?p5BB6%h%vn!XpssiLn%^f@>*Ih`*odW{tx^A0pu{um|NKFtm- zkcpg(>b9M;JjR`zz6vXDu1|-y8IYB=>sab}a4Y>Uo7o^pcjY`J>KSM!FSXM*TSMvA znTy9Fp9;SaJ7Ew_H6xiuB=h;o_@~HtJ2}!yTy7<^uOBya`9>~(n7wzHm~JOVnhCj) zkh`QV-T{0pPD&GJn}c#=P(GC8E|~xdiijmmtB@~M({NbR{C5x>)wD-7-Rwm;$3>Vo z%f!%+2@MhT2B2YzQ&bHEimuSPO4JVMu+dQF71sg89PkHfo86Kt)pI#^1-@AWHO0nOwpHQ_d z^5xJ=`d3a<)PKf_blYFR1d>UB{!bj5(cu_|IRaxxVDt-^I0DK~A;x5W0HVC&-}^2i A!2kdN literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2bc7fa7e7c0ad48e1211ddf6b30ef222bd6a999d GIT binary patch literal 1385 zcmYjR&2Jk;6rb5Iuh+5M1d>uyU>ih(p>i%oz0e5qQ3MntQB|UQSuHzbvSvTb%s6Qx zM?xhBIC1EqMJWO#B#I)W9{B^f^^zz^iAITf03>n?1PSPgnOSe$k^J88&3p6a&3nJ~ z{c6<#Vjbd*=$;0^uhJ-%F#yN!3vde{fM5&~;DH2s5K3E%wM6%Hh4t7-OwUx6y!%>iE1 z`da7lbAyPES;P)CdUT3D&18PHkcM<-7gk5jsjzarspt9zWYxmMY6aRHfTAK~AhQoQ zz*icw*k}>e87t6GvnLDOnJCau7x_A;hI<7n?nI?K<`wXxz6=|7iyMoKb$KO9mx4G# zzUb`g#GAR{4mI>e27=aFJTrgV&sNd%5ciuHpvqAP<#=|s_$PK&}*HZAHM5YMMM^P1h2L9M=O!0BN}2Hb3_mng3!1*Jm#0sj32?2n$h z{rt^mKC|{}<6o?OzP4MN+OAFAwSK8h?~m1QU)*|W_UE^D=dW$gU)ve;wnn_)PS)?d zvT@;Ne{cNk-sFX!^n1N6_u}5kr}pcUU!VK(+;`W%?|<9hsh{7i&urIccIvPFS+Pzy zN5CpO|54eG8?)8f8f-NzZu@>JzCqvTj_>D>6nMn>v=(5rf!rqKD1xWA2vf?7N+DN ieO8Dg9YXj3OdWtT2VmlN@Ztez{9{$1^A`|8o&O*ChELf5 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ce87d5a65f6c0d83ac3e2283c50ce5a31eec8e38 GIT binary patch literal 2920 zcmaJ@U2Gdg5Z=8zpMPRIcATbh(ll0opr)+@EhqtnqBc!SMTx4ER&Y>`lkeOIWP z&dl!4%ziU_zsF(`1m9O5ePO182>r<>{uih-%fAA11?fn~7P7H|G2;bGuti0*B}L+M z(F)kIB6D7{f_6v=aXw&$?T8ZLyll1DQ6~^J{^DWi}yF=;VeAL=#cPgD23Ft7=W79~F8{&*u^H)i8X)8z*GeTX~HJQw|{lg|% z?1lFqJ2F}trv}LiUig6K8qb$>!}5a9Qd&MiEHG&sI;}ZH;|?|L=DJa%Tyjm= zeiH1Wqgrv)pu{vL_`3LEv#5b5bjUK{46sP}67Y2a$#Vk|e=`lLRm>z!ANL=EI5UqlCH5HLIeMLCU`6T|k;q>DUl;0pb#MsUYitX-Ns_ zq9GRqT?RflDP@CR3%?DxcL>3Y5v`~aKk;6II|c&(YCIW~cT1*N*Xe^k=L0J15eZuc?SL^}WwYw6!U%NIvGz1t?1> z|NJx0$u#B5DuIf89XG6lbVEafD`>1&>o-zX<9mFETL8dRO|E6x)w%O*XrT{cQsw#imTm=9zdBXJ;J zr~tdFhBHA}8VJh}Vd*3xMp!z%AgNbf$dg>dDv(V~#Wwara3U`xFHTwcsNf^7ACh6} zSj}bC;-yTsgUn_7X6heFGb<WXo__?pGKKIHxA{qZ6^h;KrkJ`t{nZwCVCIO<22 zrEkPHa2AhbB@SCJ!kt#j&|EyxrG&JB7g>Qk3Io{z#APLyxxgVs_5eTCw-QZ$W>mtT zuH2wi%4?RpFW20c@3IqC-u)Zhy?}0~d%o=btoK%W?~U}{?}mO1{@n6Y%iQA!=hDNs zQp2;+;n~#iJvR#W7xxU}@9|U84}n1p{<16gB zgl|4HxP%zN1-yWrBf@tLZxkhT1M{gOO~v7c6Kl z)yifqjiK_SuECq1C#+Cvuj6)j1#|URFb8;WXl|YM#~YouZ5`W2+f?1!ZwT*>-iEcN ztbwwA<@K)Bt!{0sWqRqgR{jaRk#1V6Z@!W|$*Y}!BBn&kQKY zVvHA2&mZXFztF)&bYKxZwulB6Q9q}-MfCI{+P{d7-jOj*e)fbH5fF}z F{{fYZ**pLM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f4e320c0d19fba7d0b5b9f6596d8ed8940e88f5d GIT binary patch literal 1366 zcmZuxO=ufe5Pom>t$rLE;i|5ZP}Cnv=!V9+D2WSkgTW=3wyJPK54t?oe$VcTwWPjX zyIHX@J@_C)LkSctND40W(Bd9?%(3@gqUzxEH9h5&n-diZIdx`No5Udln)znty_wmW zdHQ2&YJva_Z{Bg4LC8Z?QetB;`T)Ql5kydrY|sX!hz-wZ7zVjQgtk&emYF2lGFKer)rDTo4s0v_@NYbE!Gz^gyhBO83W(hOttq$Nygfu7l6 zjZ8@GoSv+!_C_c8wCxC~YWhScR(&p9r87zfo2s=st|!ihUt+><^dQa|(d*PdOG!Xu zFR4UMYnbw&e0eLXS&CCnNF@(xb5OTu^@~N*GcQ(}$DGXtzooY@U_o0?%xM4=7 zntoniWTtgRghPiC3Q1%f*KJnQ*>N}Y^um>oYu9*v`SZ`}*RJx#rDaX*oWe%v9OrAU zCpp(yCH=OyC3U9bDA@|M(e`y_T?VqXr7b^DZbvihtj#p@rMIT=>or?(e5ohl5N~fu zrCcF>Z1dYdPv3PqoytaAYKPb=to*$H#dh9{z)LwWiy!TG^x0PY(!^6_$ zLFw|aw9uRU>+FT^7yEAxUq91hH}iKFk&hrMd_l|EkjK^pI}@)-55OLYND~jfL=n9W z58($R8Fyqf@y1J(j?t%~0ViIuO2Yh=r6X-V8|d>eX@sl9wrX8A8(a?*>=)@;o59+- zIe1z**;;K2x3z9F^%{6|*7f6=>b#PU;DLlI)8Md8RRp0<;UU(62kB$UFemj!Y{Hpz zr;iN*O_uCYUwXFCGyP`^A4zB(d-|taBEd;kheG x+Cmyzy@v=3kCQ&lT}rC`NSy&2{y+FLz>Z8x=|AM;-z5KEW`fQ=A;98m{{z`Y literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2cdc54948253ecdc60067337c4b1ae0e819719d9 GIT binary patch literal 4919 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..52daeee82db07ad45fb0138ba4910ad1c96e76c5 GIT binary patch literal 16481 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 U$s8tsZ`;SvTfU$$B$WF90Rc)dI{*Lx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7f6fbb0eb11b95175d3cf97bc27511b7b5ad2c17 GIT binary patch literal 133 zcmX@j%ge<81ZrJ#GC}lX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!Qb{Z*(9g|JDa}bO z)=x}N%`4GQ%}dYBOVy8$&&*>vm-^@JuEeJM2p4t0T{)zxTRPcK&LlzTBW1q5r8`g{a>-CJmbkQs17Dw5>#^awk>VtmMWQ zqPk;gdrI?mwYF<&m*kJg5w@Hm90jfdPr`Y)(eIJEj*5ytaMna5V`Uq$GRsRAWonTo zx{*6!&2@|Q0xB0a0yVBbTsNT}++e2c7^T{=Klrcmo*0>O)%zF|&M|(0g$!Z2FWbb% zVyrF3gT;8T7-O85d5dvWcgxXaczGnYrB;4AG-C;0HbQ6)Q+==JaP{rDTh^m50$Kb4 D%{Qo* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5195dfffe7d026bad3ca2e8b639cf48b520f607e GIT binary patch literal 2398 zcmai0?Q2_A7(e$lH@D48)2^=D9O>pZGhM3nWg?=Ksdb=L)`5_LmzZ;wrQY0R=iY2f zNTH5_DdM_`(1I`YgC90FrM?X7#=1YiFE_NXaB(PzAN&@qBJ7jTIX6kW8QFn6=Q+Rg zoO_=4lb=E%9l`i&>5E)bLFhNOXb-u?tiJ`!Ji-VICdvybAup!Hyp)mzhKZ(}S5gY+ zB~#4@QUT7(rj`$;f&voJIKs*~gw-pe|HYJE4kWZc*dif$(kl}$d&=a|u_H%4ebPxg zWSpjpClZ1eBl(1qwuyP1_P~zK z&c)+*N7KdPNWOqeCb37-%-#`VWpfr8$ui#n*4D@1K(bi&G{eYQ;4h=Hv#Ykr_I{9E z#K;V43_MhmKMJnH0^OrOa~juw4VZc42uy+vr(@-n7B($b#VN?0`v(S_r_}pVgxXmk zAUMa=xNkT-BN>Q=2gzc=>1`jsM>f(%H|DBvRcz}#35hqsB6liBPD3PmGbc#qy<#C} zIfN!O%FLkwAfA@S*suzic)f-%?yopbaF$T!D$0E21u}(vF=vv5M57EGWT4_lj0wc! zaoWeRcS|`FSGISL6W?TJ*>3=uK|e>gefH97^s$=K&_Z+J+3Mi8XCsN8-!=HEmBLGB7tiJALW#+VpDVpsO9E{rq~HRoOl9W^v;2jzd&8$Ga+E z0gG3qD{L1AL`|4#@jl{HEMuh|0mz8%GU_H7>3?Jbn`EFUyJl9fDt?MS7qMQI_}o*K z*_@Ty@tsz>=U%sb!@boF&DD)e2g^e^yxDRbsj4{vawj@b8T9L}Q-?`EIZCgAJKT@! zAG){P759dFvp!cpA9t7BoAA5gF4xb(?*sQ*{eAbkyX^iLug}275AHYaN_{2{%#wQx zpcU9(N z*v|8MW#CXpKHicCG<0Nad}HZFJI1s!y|TT#kx5*zNa--M^g>s|R@`g8E!W)}_4CYL z5L;#-^UC+`cVOUI;JyWyu&tG(r=~6YG}N9#St@!`mN=d~1qDf2DSI09KtdsF69K=r z9tPC+hRl57NnpLFmuy0z3l>Wb-2(zPD`BWQHhAUHtpHSZb+5b4_08(U{u&6BpSG>h z+t-NgZw$QicR&v6O&#@boljg$EF4~>tFisH0IRd|Y}p+;aEY!)Mr(2-+&`C_%`JpL zAiTdOHMVS@KX&ohudy8qZ!a8nqk9{%eV63LQ*Jcbh{YHC7Zt#o0XY<2m()SMrZ>YV zHaI_Yap-p6?t0&D_mO9p_0_&ZHMOnHNMpcgYtuKhu-A_>T$jy@qkJls5exs(~nFZxr3Z2yb~Z&Sf0#smsX!AZuEbwPjA&8T$Z`bEqi_ z%GQN9n+T>Qd7Vw`s-isC3=b;17Dt*0rX~CroBS`ak4z+byoi~$vVL}P${VQPN?Gsq z9)$-m*?Itz`~gh*4`7mu+sXF70zX~$JV=~(Jm0)6|CNPj*!Kjt@d;31!|uD<3%(vN p(q~|UJ$m*|AWcaSgkR8T9gY5u#_yo9JLt)O)L~)Qp9ly)|1V(WgS7wv literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5d55c828aca0fca007b5b40b28626f3143f34670 GIT binary patch literal 3155 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 OKgzPLUofPn`}`MgNpNWZ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..afc787c059270f1d6eca6d5d7b78cfbc1292f228 GIT binary patch literal 2505 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 D9Y2hz literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dcd3bccb6bcdc7457bd212dee23dddd7ee5c8487 GIT binary patch literal 4941 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 OJ|dTa2|oH{fc`Jx!O}Va literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8d231ffa080d023daa500fb1bd9d9fade81048d2 GIT binary patch literal 2349 zcmah~Uu+ab7@z%jw|8ytuE(`dM6y>zxS&@pL=5sLjY?yLiWFaths}C3T(9o!9kY8% zFW01$nv@4cqe)3rQj7^Dkl0{I2nxKK__CKa;oL|xF+TWqu^JPe{ATv9w6%mu_M4e+ zezWua=KC{0b##Ogv=4v2l)tVZ^gElhn%o>L9fQFP!Uzi{DhL^&AZEmZl#v9c6HU3G zWE4J@%s?TS3G%UQs)bM{Bp?wDBCJdy9JnU>f6Ro(f+_V+Hc3gI^!m_io-%x5;P?qI zJnR?_8Kg#OBqexpQph`ci57E|*tTwuSy|9V2Xe%6IEJH_RY%OiFrn|~vn1=E?9^8P(876TK*`s1QWE10J}9#U=ap0$PpNVHD{UTwWe-dtcs%$JZl>U z(xZX3T7+6*ARySs%vjSL9+FIng(qRL;Iy~4ufj%K+DCm=xGuJA9)`#p@&blse?Ta> zqCGo8vhS6OdCP&hN*Ns*1mUR$#=2F+#B0}mxIg21f^&qjtD@|#ykNFiDCJF(l4y)6 zyO~n)UB&>KrqNEWd$*i7apkG?{lpg;E&C{l3G_>B^OuJfV!Ni5y4rCeaz66Onc2ks zWPdH$zo_nR>OL=D99dL%EF-?zu&8dYM-vyu&yUYNH$A=(-Rr7*=>~qh&C+a@znLGn zzZ2lqmHw^@Sis_S=^9&w2FWdtHpjlkmsqI^>qbEU2V0)`0(~W7^)a~68eI4>xVF`} zoYe9u#NoQ}wqA_k_Nv&jYFv60-N3d3M^>?oR;9cE@e&)VY;5M*a`L79h$tV2J8(a9 z@75;WyY3D5``X7EpWJY7)+ROgo_kkwZ^P0Z0NiwM*Ur`^Yv;7);_Pm?^X{EA?Sic-g|hnK$)k{Xo|GdFWqIqVQ1J+b5KYN`B0bHt;WF@s z5?3lae7DUK4^qm{DY^+}mEQF;YGvlK46wfj0g0tG`zhe&@DY=Qj75J@carodeT> zmIW`=6GvJWboN|)){Q1zHOaG;M!;5hgP^=yMuF(M7uI$EZGdsRuAeR&roTc}fKvA4 z&@D{x0w&{p*5S8v75P78C5W;j%LUtGKM!IGHAF$#bn&eQf^J@3V%<_eQC@CDHYmNb zeGLTNJpP+?{-4-ShEi=_)HJM|A1Pe&a_ARER!O~`@Sdfc?^&9^XKDXEOLMrLhN$C@ zydMwSp9JUi%cs}%e=NMJ{!MU>jM^jK57qs?9S3PYEU+ic-U^~234-t|>aU^xKhWR< TH1Ghu_-|mV(EAqx!T0|MD3EK= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..033205998ecde04ec597c3dd04b2520972d8e307 GIT binary patch literal 1382 zcmZ9MyN}#N9LLA&+2`$M_ii7_J}wt2Ah8hLQc*+^5(**;k5CYrZANpRIj=c;*73|j zwxU=l=q?qP2Bk$%^G{GAQ&=OY5Ct95A&OM|#@?Ica54J)zVmze=NbRpYBdl%o8P_* zZn_BlVT_BXvW4@v0KP>GF(*WkGj|-rrLYvabJsy7bPqB2E5tB&Soz1w54eb%<8^gf z`^y+pN7o;&W6s_UJOxJM?t%)R-&Z{H3g=>l4)W*xwqq)CGPXV^+HcM1 zuA+!0Yu(Ddq)MQmzt+uUWx6?92^Ml)gG~~LROl9rfEyoX((3*t)B5jzPD9gruoTd< zlu)+&l61^DJEGpnC6OvPxv`SELWTT-C%W$C2U>Eadz=MIl7v56acOE7gDBMPP)R;N z-6%xU?1O1T*9Z|b;)Li1A<>e-TLCu-d9E@6Ab*18 zYm~J*n-8;2ck^*Ud;Jvmv)x{bd)c*dipN>IdwTb*cY6PuFH?Ly>krRfc`{G&!PaLu zN%15b3{yPJCO1-iBO6~ge%(Qe2U(Hr%I@Z)tuQ`#hM(R}@vGU@Jb4jbtj+f315@q& zU)l$zz-T|k``K{+j6EHt_-5AWoj!ch_!Wp)O!!M7X>RG?uH!hrqsgD>)<2bz OGd>NTt^P#-dFX$uN|mMn literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1284b7ed3c49d39f64b5f3cea7501bf342275f81 GIT binary patch literal 1991 zcmb_c&1)M+6ra_8$@;dvYUDN{O zH$SD*3BbnJ``@|`RRDhBi)d&g$Iu<*twMa?twRP)t@s@R@uJ@%@KdtN z966f1#g;9yi0mM68!p5Q%Y@hpY`Osv;nLyWQ7|ea-Pn^@6m3)P%InO2 zayYW1kaUE0LFB)liLj%2o8O!b3H(DW)zQdg=mWrij-`|7|0!17nIQRp8;hJfy<<}$ zGh6I6F#!|otL zbw(zLZAA5U$bTC9#HK3bT|gCwbqP&`F2wa)C7H&fKr~Swlb5FJA-%0Y@H>Nf1!>%} z8ZLsCMRg1VAKiykYulL77pH_hj0G`P_)=Z#xX8hrx|s70b7Er7CCr~8%-K`YFeePo zaxull%UtlZ;PXrbeB-NPJb%M(x2vA-bPx=xqHC(K)pT31ibYFSEz51W#Ih>w4QhzP ztlnylc!2jQO51zw=hDXVkW8``HWM*ZSG_ zwr=hzsezL3Dfutl$20x8Yd@~;&Mge)mU?qbedU{b zOM`N)SFZJy<)g@rN9a`KUneg;p52|ivUPjkxUy4xd)Ih}T`O7Go1EF3zOeVw>>s+4 z(hh*4YKQEHKuH#VD5@dSyIjgMCNPE|cP*^pMV7?MD2Wx3#7daNN|QgRfhb*ADN!?C zJ$nB1rla=?$GJ=Xv+us8%a544P=J@O9#o1R`2)6U0hhkW__WstlfeWr_}{s#MT B{3!qc literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6d415f4b7f54687310325fe970a6c0bd989e7bb4 GIT binary patch literal 1470 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 A1ONa4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c544c9c0fb14cca872e953f386b05603b6c0853e GIT binary patch literal 3099 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 BkwX9g literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b59ce9d86ab59aae0fb24a2b22fde152a3c853aa GIT binary patch literal 230 zcmX@j%ge<81ebf~WJUt%#~=<2FhLog#ej_I3@HpLj5!Rsj8Tk?3@J?Mj8ROL%$h7O zL5egPZ}B^(r{^`7~cV~M||Q-0clA!NkS|Ka!aWx5|PO&@s%CotNXx!Qd9dmTl$JMzTF3J#1Alt8vFWXBdErXXpzW}Z$1>4UFun_5k5E#Rd{UEri7LKKRbTPd4b9K( zsI{E0QO(c8PoWyD>9CgXtM-I2A8i8dTF|Oe_PkN2LaWCYiF`K>gbR()u*ce!x{}=s z7Gp<$Y=PP%krWXZP$H2Go|JigNULJYBJZZmx|>qlicmQXHdr3cG9460PRp_`EU()w zw>o|=puFs1h~*$?m3b1R)9nl0bsOzQ=(^j*D=9eH4%UZX1NDjwXXgg$o5HX-KTvm# zF{}|Wm4xB8y%rUdJ>Uk$ZZG^NQEY3R=r#aIlr-_%E7H7NnshIc_R|u)P$GpxSHO~H zHYS%Cnwb6-iIhnGDrX~7CAL!8F0FTZmdB{&Sz9#dZuA1n>04YuA{nvY6cX?K%o;Bn+70sXaQwjNdK>P zwu4WDFM@(<7fT22Y0O&9~9(=*(O=F&7S&kDABk z!l}7_tgka{m91lEu_dl6CS5m~0l)>n^un?Q0nK61JUCq^`Pb(vW_Mn>B!|LzYPiT>`V&}v>jHi>1 zrxTB-lMWLWn6fxDaqIFaz6GuGolSVob$HHB@)(jt;y~vxS}Z{v{DuA?;eW_1Si@1` av!F(bBuVFF@tiFG(I(*U 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 0000000000000000000000000000000000000000..267e017daafcb2f3ea667d0c7b0af11bc7007c73 GIT binary patch literal 125 zcmX@j%ge<81ZrJ#GC}lX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!l1VHm(9g|JDa}bO u)=$nzEYXjT&&TVg)K@1ma>4<0CU8BV!RWkOctZ2^u*7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8389fb6718934364277f7570c1aba9ff0489e3de GIT binary patch literal 1790 zcmZux&1)N15Pu)L(rRr zeniID-|M`yxw#T1s+B6-XC?YBT*)nNc4Zun1q>CH0`>#>D|>lgyF9#*JEbwuwhU1ms0qL%aw+ z5-sWOQmBVWn!_?BPI>-ORQ_f z!<9&)LqnDY<|nJtbv)M&*XPu6E-&ICO!ecFZB}^Ed<1VSOx?Ql25`d=PC?xOc9$Gw z7M~WLx=%Kqy!-q22br(>@u4yMz`SoB8u@{d-^m^>tqzt}pWQlKTOX{g9~k9fdcL0u zM}e1^8U^ljFp54XupXoi@+{Sl$8r+s(P$tr1g2>B)W|%n8c>f3dI_}X>{NuEjy&cO z=m5A@I>AroAm7qLkj)L(ix9?-NF0JSK^QORbJ_lO8eI-KTk~X0&&qCN)(E1SwZ zsNb(2rV4{pVeg%5`>E@F{%1NfJfA&EzkRf@v{Tr-a$~T(y1($@QEuU9wO9CHuy}Pp zckQLl=gbk|iE!Vogv3RYGstkcB<`}r%LeU++p~b?GO{$X;0?hk%kvS#tErEG-6JDL z_59Aw5dra=7^4`)xSri9!)NvE!`ovNBQvg>=mPQZ_OmYr?|u9d^{_bBEm?*?bNrOM zO5$LB4d3%%ll>gLhQ-NiSj5+`c=Eo4*9cG#dYtb2wXqiF-IQ0LDE_SKePAO_DSb|s jo|E~%$@Ni;u$fnk(TgJjY|OzNq4~<3qvpQ^SkUc%uYPsR literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f8e125af01d8e61dc1a4da537b63209f6ee5bc88 GIT binary patch literal 6513 zcmb6dTW}NC_3p0TlI4daKV)MIgTWGDhlHnL2r`&F026FT8-+y3+O?668qlrTwDQkB0p8qy4aIcjRu&l$o|O&7T_FCZU~a&$+8z$>22U zYIOGOx#ymH@44r7?{BNB90Z=$%RfjwT0_X6@k4)1CFI^5Ak#!7B9|m7F2bew2+!d+ zpA=H2h>77s(wwqHEDSd#ttnf?#&C1eo^nJS47VhmDObeBaBH$E<&L-+ZcA3DY9ciZ zw@}v|KB^u<^ z+Cha%u~Z^GG9bxgnY1hc(H0&X>!UP-40U@Gv1DdsP?F_DCOxQRC~~;>oQNp{(pW}L z0A(B~HAp!`<0m9pp)nu=&3+^%pVYfJ!y{5!8OWy7Y~bpF*n>iCeJ=q=Z&pTePl-}$ zETc&2__!{iYCuxxc;9I$o<;d)wGPKT2s4X~NCVP0vM{vj(dN4cR;3t3?^2?+P_+)H zcQ`9$B~f3zMuV?x5|(Eu#BneqW|LBXEG5M?)oRdH`nb0ZkZB^32q%&VFLII~@{&mu zB(rFeETUPmiWbp2>=JDkOc9%Ck?g~~Xb0ReZVftA2U`dmLv_Jo(n^#vMX7cPbrK!Q z%Bq>-@>Lg&jYMT_J7m>CjZIK3z>uZs{0awYDmy&D0kFW{?aj^ z>Pps0@Sfm+?yew|&1yqwC>^d8~pXs7%(#y6p&|}1cm;1X?d&@D#Zi; zdm#XqIZzrmTG`H+aLT$;M3UtONs#LY(-lS}1>DVDu}{&2NSmRPjG7IcyYG*UjdiEk ze#_l)Fyhij*Vy=d+s61aHS{1Eek}0ZIHRE_PMFXH~5a5Q;P0}OcbLfVX6sL5wxf#SxOF5ToY|VfYV_5qb-2RXkY<&jt6KvpsGz*PR_H8#!!Mh z1_1O^tRbzTNziLG| z>D;B-;K%S~{jhG#;r(jy9r;W;-vi)WtybgaB ziZ65f2?r)H#g78%!DqS6Up{Q=i<6tn!CK@*zPtsaMxZN?a=m0+-{N3=V=K$;qt#_x z$Nu;DXSs?1&n*{xVS5bY)nyWLL(t7?ki0LP;)14r)mITrqUnqh9nNIaV$L@Zej-rH zd;uT_7#Tust+pF_R>Ww#5=IC*=z4h300JaXbQ9_Wpb8+1MJ3e|XQcu~HCkn669I%Cm?mFp+E}Yv%g_by6b5u zcslZ)j)Etc_XMx4ErfRGL%ZiZ;f4CvnYJx6+YZege!g%xnm-(!tv^0fb9~;{G=1#S zv4XEN@9VtQPzXMr4?aHY+da8&!LxS0y|d8XlW*^tZ|f+uZOXT8S~Lq@caaEH?$255 zJl|{Wb#S@5vNe|iu$-%|D7!Z7m&QT4R#6&`K{=$FW64BJR;w#AM)agK9yh85SP^b8 zGx47SVwkw_)lGbY)6L;L6yIk+?QihY!gtJOa+X)nf!H&_j~WqAr$IXAm#ZL_GyfaL zD1&4xx!BDNkMGCTvLu-hRg0{|6evY4ECi_4QX1;Ztfc2E>Va`-7(p)p)%;Q{nU#Vj zW;*n7BtguOPioeKYJr4RG9Q;LrH!PQ>-uu*spQ`UqU1dQG~+2ch`;r%rYlW_rbqKl zkKWvR>(Fe|fr972A z4}^W(J6zafdiN2)a$aT@EO&RGDQV*@Tj`Quh7q3abPMDxW2gU*V5SEy%yn{Wd2caz zO_1-CAM)j_w%op!Ls&UaL5Nug?3AMibrDG7# zO14=JX!ddtt2qKzQI1|c0kE*L895=DfUiZB@iC~vVrlsloYw467NtidSppxF*s;t- zvFg(TTREm#d&7J7_x10kIM=dJ`V@XXjbJ~50{|e#u|fNg@Nj^$K?xhhz0ZZd+&XpM zeIRRTSex#-)HA*7(yps(XFZ|Gy?^vHKty}<%uLs=Tc>U%X4XIXyHMfjWBI3#&DOs# zQ}Y5CER#a367&vlIM5KE|x{z%wO`8bg0@a)4_RaKcsicQsJ2rvI9d&7sOC^r&=q`7j>@v6x{hc9*mVdy zx2Vo+TFws1ahe#CzVZ?TUuYU7kOK4FE{URE%K(OE%Tv`g-}mE z)N^a|uebkl`>%G)JoDU4sAo15DXfmX*|*Rh`bqcO-8VaC+qccOY%5wxbKBk3k*K!5h1Zc+SBGoK&$)25=@%`4-F74Kb~T51tvB2$-0t9!vX(=Z6CTUh}Q`qFITHbr_dMV< zf`A1;3=hWxyY&)piMGE=iMOP~5fCQt0B~O`@gQ(9{dDL3-8ZeX{%r;Cw#mMKnp{=A z++qvycD#M;ofl_z56*dx%(#veR}$ZLEjxd^Eo|c6<-)w_$E|<`ofLguHNnlBqLr+8 zl+vmZTmi+LrV7xg88b@-Y}FRFOt>Usr77Ks%owt1KY|wlFcUovbxI&ms&kiAv$@7E zveAv$`9A=lnq8>%U#-7to2v~@TJGAbzt=p~Jmc$}+kSAedDh-v&1KpclS)>Nz`jvB2`aIWV7_Bps{A?r5Y^3L=l zTpa@LiblVYjU}}f>O`$$sG(SdQ7l_1)>IUg$u^k|0rnnYLfL-M25drrS0nO402fG+ zuQxmH)~_s@;SHYXZ7Et2vyqw=MLS{+(y*rJM9f8etBO^KflA!>su6o+tGWKo@kIhJ zZ6uW6A8c2x!!am<#}e9H(6-dt5~)i+^2*GiR1aKdb;aRcsY^N?m&O#hD3;k-OI?92 zrHigEC}Wf^{iuf5Q0#6u!%}%t(q@BosJe0) zR(7`=l}dNp%qG+tJr$Fg@|bf|=J4#a>H%r2*i-A4ux3)JOq!i@HIo@ezSRiY z0l*ETwj;Y}7CxcIAo5-S5X?F5Po(1x>HaI}yhC>0AzSW{&3DMgJ7mKhvYjE}J7mYF ZyoKZcK@hkJ7y8ul49C@fLJ+c1{tGoV`|JP! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4a957fd00a82ff6bfffcae149974564c82de3649 GIT binary patch literal 4445 zcmcInTWB2D89sB{n|4>y#@1zJOJihQkFu3Wo0wE>Lq@ArqIk6myQ?(b7N+ApBWcyW zC^IAM6>FM;AL8232m-39Y5kbsQc@^1^eN6$AW#&XQZvazq2!^^hf&vbK~Me9TvpoE z$W~2zmi^D=zs#BceCPlF`79C%A!w~X{<^dmM(FQk;x^V9G+%_lHo^!~WmF(NU8X8@ zfu_iqDKizez*2}tuOQ4`N0_@u+nNQgP6@t$kV!_M3|3dJ>d=Q)6kSrPE2XMZv;}P% zaVE`m7;Ga26)21fG>%~AI$K~ctFV}ZKZWUKANIi-XRT3+57V1|gqB(ChgE+N1rd%~ zya31t;U9v(C4+lhY0FL+hp%&mK%EwPjc9UJ*0aibO)Kd&wQl%SMc+`XnId&ymp&LX z@NZ^dbsOnz-aSWXjc~h~%XWA^;N?5)`VVar7M0s4D=UONfGB9bIKEp(~WYW#kv&-9x-b zOJ-AIKE>z7nRJSupW`!&c|P^#d@i5kaY-)MRwPZ)v{J3A@#j4a5SyKnQVH{Kh}q<; zV)p!n=LP=t?EHe5y~JNkT}m8jLY3Q3>eeS)$}FYRM=KXqMb;H8$vU5(Ur6Qh;==3s z0zaFY6PMC?KDm_5rZRb{&GX!a;JGS)`4pZj0^p?z_)T6>9_Kf3)Xe-Vxm0#uOdrop zkN5PGJ*CUqPrCejv|H6$S!ti-a~A{)98WljT2)u7a15z8;n1FVaO-ltT$3^XT5d5j zbM!|3kLSBNC#LhMtaAZ64^Et&<&%r)rG-q_&G7Kz=F3lEr{ncM;8cfkYZ?i(+1Gutpu$ofA8w%MXTEz6h^6cF4+) z9}LT1gfEkoi#IObT4;S2p41|b+a+XAgA|Hxi{F_UVLln*X3qMJ{zDhRh<1jM;=z1D zvVfIc{se=k$SzESIc|1oPz@T>cj*1d47&fTNX}$7w>-ntdm(?( z5RhF$_Ou?be0xglXYTiNV%%SJ$}Ct#s9l%*6DCiA9J)at7de=2P$kO6&Eqh$Mt0oQ z+{)k@1I1%UW$;r*?a|#L5H;HMNe-lPhpL8KLr&EISo@tP)^e$IDw*$s86=f%RCy}T zWfvE^R5ee*AH15%rn)3a=K4NQ@aUZuv zz7r7c=7F>ZVci4_pLH-iElx1^CphujeuE{++sY**9~*urWf^`a=M)LB-9~(6g~xxx zU>h~KpAGI8;A;o3gQL;lF!kGZh^xZ)#5JGZF27wUuCTSP-Po*gK79Md#C*!1Vyrctqp8fYyTPlWsy7w-%w#E70Oz%RNDNrC(I7U+RlQ( zE7CfShsyy6lHeh0lLJZc&N+C8)UMFmwlQrRnQmK2in;vxX}69Lb9N;qcv}@X>xQUf z;41YrA)|!wKmwhdTuX}(uW9c9xsICjC>J+hrh2&e`&2cW vCM-Lo2G|RH&lY}3oq{R(JlBY8OopPUFHz6eXyj{@X!({XD*i7-sHOWK|KhdJ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..167c1000d54e322cda5c2c0642048a1fbd30ca67 GIT binary patch literal 15649 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..45207dd398585e21c1a26c7cacb4fa4cf62e76d2 GIT binary patch literal 2497 zcmaJ@No*8H6s=xo_jq9&_F$XPX2y^Zgb14un21GI5^xZGX|<-yFs5h5sp<)gLv9l$k|m$=fAuW3f%M_mUuyfW-mmIk z6NxB+?>G8Qu16*0Pc&Me)>Il_fKnw6aU_@IB~!}FrYxZ?yGmX)RiP`cme);P=&Bpa zht06iH8+xvn$djBj0szJbYX}DzsXulylwzC3@nDUARn7do&M2x-QuK>W7q< zT<=18lKN4!&wI94;?Orf!dQ{LZfF5K|r_RH3RP9R@Ge zCgROr%NjF8HRvH@4@WV>G~y^zA?U3-YSR~`O53NQPbM@*W4f?&B{5x&P9HsevFC4Ee|u-?E=3>8DyPw=(r7lSmSoq zE3&d5$qL9g^?Z#YB1VrNwyc7krxJ`~8y2c!X#EN!(XC-(#k z^F6RZZ;!~2!;b0jWSJrMLf{~N5ya=@d7}HXbG20e@2R2r)X>bS%9#aqsAgnl&feKN zH!$C`b3x72Qd=N!;?BTL2yXdf$D2Q^k9QoIIbHGYoS9EbUHvplgmsb+jEBnJ}(8I(S1)2t1M_gB=Dt zT$YV!>pUSxUuV=SvBGG!$qHBosX_>Hv#}RMmE_1Yu|Nwxuf?$G=mGQA zgU)a_Yyd?+3XDIH*&sB&ifrWw{`MxIdX`b!qf^}-I0FwBIv)WsL!Klz-0!oFv&tn>lDlljQ+SQ*xl}s!j*X%OUKC8}{>GmmQWyEzkvM=LW zCIPB!2h3n79Rz*lGWCoAL9y@U0J1dno=SAkgTC9q#fL!v4?9q$YkljhhwdJ#9=m&N z?!ZFdZUid52q30kpjhHam0O$MR({kDg_r@xs{y(Wk^cn<@P6ZO1yLD#odTD(vjm8u>zOZQ3#RA2#z(8*|=BvK|2_Nlh_V( zj2aQP23m$y&(@*Xi9+1KL#T;jz~fjvWcuIEHbt1 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 0000000000000000000000000000000000000000..76b1ff3fa1afdfbf5ced1813a8deea82d8984ef8 GIT binary patch literal 124 zcmX@j%ge<81ZrJ#GC}lX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!l1?lr(9g|JDa}bO t)-Os-*N>0S%*!l^kJl@x{Ka7dk+Cac1*&BP;$jfvBQql-V-Yiu1pvS|8PWg% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f4f1f64967efb65cee06e482614fe3d20c5662cd GIT binary patch literal 7550 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..aeff80bd4512760ee3c760949b63581e2e86ee96 GIT binary patch literal 15646 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..71628b30660b3776595b592e540df073504dd85d GIT binary patch literal 7405 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fd56ff574dc456d72d574ddba3fb1cab567fb595 GIT binary patch literal 2067 zcmZWqO=ufO6rP`5X|=XxS9Yw#PUD}X7@1b4*oD+hdTF7TR1J1piVj^C?Z!yRk~*`Z z$f%Hm3(leB(1U&G!SrNf9}Mjw$J~3Vu)*%)K%w-|o2UYTociAEuJzM7ym>$K^WOKq z+5K%~#3V4PKmJr)(FpkqjcDkh(#eCeOB~|RGTEegN>OKJw#oBc>Ab9M>Uo_KmooAR zakOpX=tnHre-s{*kEvJ;V(}=Jk2j1=;vZa*p?<=#J-bl0r36E-*Ne^yC?jk3#+qBJ z%A!{ljY4>uns{h*=0VvdF3D4e#mf=YQ;|qd&81} zVHF+UtchyLEqI{kGP-YAR>j_QEz378YqRRq%cu`q);D##97eeGgg zW8lTn`4U98YKOYqVGe(!0sdNEb97gCm>UB-26o(ugB|%|jsZSH`CyI1w;%;rnE-DB zkTU(zE>i25uWyR0CipSodUa9B6~Z2?d{D=ZbW$p>CT$W+1yD4g!8vTJg~w!=uSvK3 zNK8OT;>Ouo@diwYda+XUENkofKmyq)lEgg;5Rb{lwV5652QvT)U4025P%5FHLD?l9 zg-H!BA`bB)l9Pz0N8zd_J*E*&zhg~OieQfpfw_cN4vZ!%sVCL%2Jdjc2d(IQN}6n) z9J1UY69)JNo|}mW{6Ja=c$-9|LHNmfO}fI1h}*NCi|e=w_xkaWYZ5D+jlhi+gll{5 z*5n|xfgc+n-v{xST&8XwfAp*VOZ@1;^ZZ%r;ZFR*7=CI#G0%;;mNB=#a6W&xHGlWn z!ujfh*6M>Z}-2rQv+1u+) zgIHuw7SD|P-TM1Dh%Z}}z5~iGfdXM39Hm~c!eEm|Oa%5ZNgF*}_3%_Rn#`d$K#SJ) zMObg-m`8A}11PyJZ^TX54=^cq4Q;qxYT+#c=?ADPY9WX|7u9e3X~kjF|KS5Cc0_&* z0$yq|^|bV)be_D^O5WN3`ZT$+!~YmfU5uwMM{itC-`bPE-(GIbtej52cbT5rTi$E7 zrr$nIzw=V(V`iK1MDWNnroh)FhEU*(637jKg{n-0?$4k(hvF?1qbSt36d5kYBY~w! z{JCwDHlzCN{{1$A>E!bcPVE@i)BEFax1K(j>fqEi(REw7PL|d5Y&DF;brT~n9W0)B zt;G*sq8?m}H|8>iKXmnjbEUwZ`x^e`)a&-A;CGP?e-~L{Z)CgQ+n`9*4wZa=L9dN_ z!L|=Y2F}FqmwXpQn^Q_(khvFR@^7-zjuAHWiZMFTCLlT-%r=^@%xl#Amw-^o{s$`z Bpc4Q9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..85e6bf729ab09b81f1ed6de8fd0a016791658ad8 GIT binary patch literal 134 zcmX@j%ge<81j~EpWP<3&AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdrJ7h!pr4zcQks)m ztY4IvuAiEll$w%~nU}5~AD@|*SrQ+wS5Wzj!v>gjEsy$%s>_Z DT;m=t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7152d29622bb0da9f7c1b873b906bad6f403febe GIT binary patch literal 888 zcmZWnJ8u&~5T3o4onXg2B0`Wr2}-0~b8Zp|5TuDvIyZD~R)=@(TsS*s?=0cSLKH5b z07?gmB4w;J`5!by6rh!;sOT(=gp`VzTU!V-(tbO$JCAR6_G7U)4=9^&KSYZR;5!*p zaVE-m4V3{15G;l!t1<=_?1HfRAna4icvY>gC4kfkD5=U z5K?Kcmetgrl4(b^Dzynij07YOVB;EJMHoPj^?;*hqzgkI?Z$#X{b$zw)(>=Xw~@)#7=@-0QP*KWoZ ztxIUB62hcaRiJ|_2#44Iqn-uj$$1F@d6%xe>%H!MDXpHDR*%;|mo`4-H`D^&`E&FJ zt*|LM*Ylj4w?n 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 0000000000000000000000000000000000000000..6a3d3420438f85535b5f11e0fb80ccbe420464ee GIT binary patch literal 134 zcmX@j%ge<81j~EpWP<3&AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdrJ7h!pr4zcQks)m ztY4Ivu3wZ|Qk0ommYAa-AD@|*SrQ+wS5Wzj!v>gjEsy$%s>_Z DW`!PI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..debcdce375297a7f2336604339454af3f28dfb79 GIT binary patch literal 1399 zcmaJ=J#5=X6h0n*M9ZNh%aq{Obt^bdS^|PKI70`wOKKEdyd;wrf)LLxZJJS!v|$Sr z+5j3DAWaaofHZVarnc}>6zJZ)OI-yBd+1Q0Q#LgUP{32)@yGTM=FKbU zs+qwXB9naL?oi$YDueJjZllpSU$eU1*16y9|FTEE@Uu!c6A|?e;e<$lRk08z=DJ_5!cxihV zr6k`IVGB0SZ>Nt#ymz%ZS=wo)eY}hApk365JLyZI6z~(072?wjdmk9k`$2xI=yf@v z6rw#f-+QThU|w*Mq4`oMzm49+sIP0lYhbQP4x>8?{iE;+U{YkcrFxQa1cXf1xmWE)geswVFJ2Lbcx5r z1IMGh9ZZ2iA)XBp>zK~*Ed|P78|7qJq2m(EBQ{f#sqs08MFS@CEppc(9pdx8!^rVP zc~6*}j#Ls-;H&=&2$-j8eb{``9372=Nwpaj;&T0Q|3UxZm+@jP`Ye9u-AIWm z3y~a`7QgL`-v7q^qqO>0wU*lBtsg#l-rPKc-`eJ+`cZT{)|W?HX5+7eYcbUQ9@cCy0g&I|Gi)KIk5lU58{t zIB4PnjFBZ`vWXlZ#Je|tfERfjaOCDiZ=0+K;bisfLlV>w6D#TJ>gw*V>#M5%Aj{Q& za_Q-c5hDO!_@X4=}u#Do#h1fA$YGx{{KY)QH>?$tc=(ukNOiuW78I_MD9 zA!Cl0>ee)kMFj3FN(Ig$j(iATT>=|uvrTA|g{y+tuu-%R`AEq(c?E!yB(XS#E1ED5 zmZ!9`!6qBU_pZT4(OtSdD_Hz}SMFAptY7mXRB;|-JBm<75|51}Arkoul4OgTXYxD% z^Vm|s;tXxU7Huho%u+spo1l0HCkm0~-bc#WLJ!YG4-U6lH-NZVyi6Nzv%u1%Xd3sZF>jD@6z zeor;SEE1;}O+l{axi#JoW4} zs;}@P4O?<>S;~-1_d|7|?TNlA2lttTn{y`{$nW`e;%=E=73YS`$RTFF5;MwcOYsOO z0_A;9F@?Z*UjzbF=8jra#|xWdmHN!ktH#xy?9`jyt!8Dnu70mt#!9b>*#Y!b z?a0CH>R|fnp6q`%{AlH$g@ekzLa+DJv*_c0iv>~T jL_)}C@a4Rf 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 0000000000000000000000000000000000000000..03916cfabf96275e923e9626f9f961126cb7bd98 GIT binary patch literal 127 zcmX@j%ge<81ZrJ#GC}lX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!l1nTo(9g|JDa}bO w)-TRTEJ{t$kB`sH%PfhH*DI*}#bE=Hv@2o-DrW@ZVi4maGb1Bo5i^hl04a_e5&!@I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..52f9c1c73d605cd5fc714e7dcac56d4562269139 GIT binary patch literal 933 zcmah{O-~b16uoc0YTH*^`Dg&EfSAbCrrjGtBGF9~*we%`L+7O}w4L$IBc(}82yp=h zg9#?G@DCV%1#aEYqzQGzm2uHs11wD4_+CG-ZuBhfoqOLockY}wp929OaJ1ij$Ztyk z-$gM1ue-RK;psh?V4?z~M3xGuD5WF>reeykl$2aSy4n)t6&<;uEMe(Pj$}4$D_>%S zW@K^Hhsv|4uOg^7wI(zE)Pspk>7@VOXYgVXpr&}C3hToY`rMO#Mo>lnIhX!@&cse# z{F#ZM0t!^+9VrRBs4DNGIbhm=_Yi2TrdHLOT;&dI%mYwul{-^Yw^alO5#}8**Sr1> zs$MqHN&kxx3{(f@p1O?6uqCfT8R^POOVC;&5rRDEnyXTylqGI}l$=dU(#%*Z5QhqP zT<`O(46#|>D!B^F7m2Gn1ww2tx=oF2niAV`^30+YH<-y8Y`76JvZVA^$5${MmL`;1 z)b$vQk)q99nQUbo;lWH7ooU0i6GhA1}w(Q)qajgFo?4NIqC?~-3Vo%Sqg>S=78$4{Ed zJG$g*l(0=&TG18i=X6(i5bt{s?|Bd({`b3g)BWg=aK!~cM|tSnTIgvO=_D`3H9O*( V+cHAvEBJrF!nGDe<8*>Ie*>iP+3^4X literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..db4d8b6f3b8b852f576ee7f6886839af7b72b3af GIT binary patch literal 1365 zcmZ8hO=ufO6rR~1t+digN}L#z*hWp77C9i%LkJX;pt=D!NflG4Q0ua+cQ%ro)$V3y zR9A@{R49#23#Rno9(rpp#Xa;!L*V(Az} zh&rc)U?LHDdz8Bfbrw-ksXjg%jM{nRUJY2s_Jew;ADz7vWs5RRz!vI^Ta>faCU1lq zbpqG29cWsHUYUq199*)3pxp9F+jUr(H7x3ovfXg(hk@VpxI;@pC(M|(?|CNUwCUAp z4)Vmq%CPj%P#^!Q)SDY<`m?8>Kkbjt>}ppBxwD&3HlFk^mVcRi{%9vx=_f0LbhdYU zA5zc>0tL*7z+F&)KoJnhHG~nDVyjo-|5LOga!v_P#RoC*1oLQq#re-tOye5jMjY8i z@fED$E?yBmoszf~MqO;-BQ&;{ZTM}E@L{-`%jg}%@T!E-5+KK@gFcg&&?+v<)%!&$ z)U7u6ZNJrO@-PjfXFjlO?$b^&8LH;pdvkZF1}_h__(nBBxnuK)ZYp}GiU?4d!`lHA zR75~1*#Q$E3I&=K?tI6j)Ja~-C5gE|%wV|8BG zQ?BLJ>(mMwrK2WF@o*n9kQq@_0v1`sOn4rKxHKB#(%}%7$a3@v9MYOI&0~KGrWq=C zefuFjC$Ncok68K%(Y(+Fkq61y!+JO96)?rO!rq1DZy96!JG!)o@_Q)z0$qQB-uMfZ n{&;JqXY_NIchJlrIli9S%x+|Nk`q03KcV2nw*tT|d$gE`_vT)nC*o9>Lc(P~uTF(>B6t8s;+ zPQp!AlYlilMPf8f;>X~xu*#4GtdeS#C8-B`HA`|N4Remh6^3dsM`% zvU&ELSzehKJu%$PTa|L9a_)j@&CQ;kzi{blO@=ZQ9RMT>_oo3oLjfLc+|-8X?@Tb! zRtNS9G9so(pP8MXJvCb?TML)YuS_?69@H7-x1Ie>-mdx0`b?>0*QpoGD1DMF&Kx;> z=wN6wG@II_5eA=iul+1UIcm5bC-f zu{k3m^u>lpM8a+b{*u$;%OWXZ&3D~KU_@8~?qf1dD*FG~O-Ocw-n5qNTHv!b+XmiS zVelzXlIG@aY)7xS_jPsk+S`Thm1`f0*Sqm8y;QNQ7Zp{=ZvrR~&~soPfN5aWLh)8PYDKNHyzyU$!jOyow4vdUo{ zWo<>yvPPaY+^Nzb8#GLZr21b@e#ei6J${1if?fIS_;FA_BN*e)Xy;dy_>OjelHXp? pU$#zmVh<u1D4>cZ&1Ttq!Rze9x%Vz~ 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b4d7b6f54dfc49232e590751231d51497babd871 GIT binary patch literal 2578 zcmaJ@-D?|15Z~Lq)0ZV#{t9;NG`7_w6#=^`jmd+XV&b%f#%ihELPVWI++7mM`fzv8 zI2JMlnt}rfw%Z3Kw6retAt8b059mvt`p_rWfvAT=TS_1LrdR>fJauMI=QwiOJ(!)@ znZ2Ez-^|?o9EpSojGwHpGL}lnpD1`kYcTaJFco4FQ^}FMl2R1pX^!S;iso60Ntxx; zyq3}=ujYJtf6A{AOQGalVro~3>AOqa_uXy0HWiRMKj;FUE)`q|CPIIsMM80cX4+00 zxwH@o>I6Wr>`dNr0!T~6f(0h(*`hH6bL*M$(-R-OKXp2J>YP3`b=GJcA}7`m>Vv>k zh(%J0NmBG9VlhiKl~Gu=G~g(53oM~JZD&j?KU=h|g0b+SWfZyT`0ccq(KDtKGPnh< zn0neyCVWmn*9+;qrRz>e*YiblE{A+r*FT+0=NcL&Z0@w`db&_3+G#seEC^la5N~pa z)Pa&`N7A#i!|rY|EH0(FWe#Va`=ATE$gQE-1s()zNdmx#2$0Xo?~&MNXRER9hiW%( z_w+ABzz3^1>PaBaBVdM1c2!L=Q^h!71WJBFE~o&Qn$qN|4>cye;4}TO<}(A9U&^W( z1Uaxk6D>|`SF|S4QOCzEdyW^9h9Zx33F41X)L#cuAsJF8x~lWz>Lqr+yb1!KntFvLUU4OBR)LhPN}Wp`VF5e^&il_Tqg-&=tBZe#Bv zEt4!_*))z51>%!!X<7!cOufC*BD~iki#KZ;WyRaI$jXd|%PbpfG6Aswg<(*rZg{+! zRLCTWlVe0E^Hd?{z%K>xxXjLxd4+ewrbJMtvcn3+d5%rPVRN>@_aoP;8#yaon46VI zStjq1`iqv$vGF8S-U|~yfaD;mAak(X1O~4kh6`s4Y|tA8SnAt@=q@dyFQ$Q9A)C>C z%eia0uTOkCdTVqe`q~n!M%tEB*HTXi3yxI7u}9(l^>F`M$HVZUYFAHXdUg6y*WvZ9 z!)s!r>*&%uzaBceGEzDD#mW1f+kV)wjoY@6JQIk^{n4=z_FzOCd)qHBW9+0bD4~wr zRQVB@3PK5U^FWZFCw`gQUdtsfOBs77O|r6SzxXx}-g}A%9Gv-*R0xKrrju(>09J_n+g?ZKs6d{kuB1B;?|++R+pBs zt-O$EaZJO6=Awc1B`eKgP-KqZx;&eK?~K5t18f?##s@$w_3uHasgTi1SU|3jnvWb9 z_;%vf#G}69^}gYazGK^(+7bFA*1f`3CabOOE0dM;tLN8S1}zlbhog zkVld9A;HeaUqx~l2|^$*#M_XQA4lvEyvrlFRlEn}DydVd_0rbSoea&&DHwb9#CsEO91el5OsqDEl4uRg`eJqM3SCPGfj&R>irIOdY;m28wj z`x5+2n3=*w_k-3BzX3zg|12N{VW$hWkPy1%CY^rqBF_S&?q9=6J_N7EkBWE`NR26q p@|X<#Mvgot`~MI2VhzRZ(0(PpMSw`R{{@JO{s900 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..79d096b511c01db0a272de300617a18901bcbcd5 GIT binary patch literal 1296 zcmY*YT}&KR6h8OP&;FOSAh4|%2-LD#Spx<%q6UyOT1yp(v1`m^+_~V|nc4NuY?j5v z5K5E>O!TFw?L(V1N+l+mG)CW~uf9N=SR65pF(&qH114(q!EmR*1!ORn2;4GHHk&D)RMB2C1+(Kbdsr9YE~scfysuv zLF(4o5QXDlD9d1|8yG=IHe4a;&}~5{(vlY$DeHMxf6ZiGcuY4Y-mh|(bY$B?W6gDf#1OAW%LSTZm4tWaK z?ODb}-d=nBx5Y$j*WGt#Es-3EIl4&oU5 zas|#1Y{%7Y*YOvro{S&6L%8z&iG1#ahwFtr#k=rT?4r|yP?HEDKf-}qklBXJukhf{ Va%^Sn?DLD{4>e9wdw}43`4^9AGhqM# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2d3632446a15c5ba13e4cd21e323b3bcb16dcf61 GIT binary patch literal 136 zcmX@j%ge<81j~EpWP<3&AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdrJh(&pr4zcQks)m ztY4gwSd^NgpPreXn4FPVq8}fhnU`4-AFo$X`HRB_qROs_6{wF9h>JmtkIamWj77{q F767d49|iyb literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6a0c283a5b08c2999be31de1ff31c38d7a477733 GIT binary patch literal 4173 zcmc&%O>7&-6`uX$E=5YDNzt;bv|9hjHtm{}x}NJNU^QhtpgY+dhm_03^Y)C=zF_d zO0s3gEl_j_zL|OR=FOY8^WHb}hxYb10_FD~U)L2MA%DY;Tf}B#@fbA9L?arPCc{|s zX)ePL^BlH?w2%>pMY}DgrHp6T!x5ewCz|vD(L6VKN6WB0$wlRd*f|yDtoGyjX!7W# zq&dV`jsYX+GR{!b)U%@o@FFhnZ0<76{($A)(>2OcjzWienw`b-&?pm1hB=K4^BPA5 zji;g}XyS+mED?t#%|kt!41a-ofTsXY*1S}qo^F^6Tm^n!Y~z1K)Y?FzZBmT-tPaU#n&T{cDkXppx1bBKzo-H!lLA*D31~T`z(bF7dy^!} zor?07H_=)`PS|T0QNa=onjT>a2s1Ab1C^?(y407<=i`~2HlC(N+_;ovREv*-gHzz( zc*?dpmY=kgL_*K%W+E}wwR%G_cfi&f1g$?M_3lm6;zu3zuD)sUWB(%Xh}CXP8Ya!) zb~Aa?@{JpmH9M;|EQ7GU;a|blVZ&q$r$14~R=TJ;cC%R*1(@TWzZLVw-0!`Dr*Q>N z#O>H+%s%CA lWf`7FX_QnEF^EByZ^P_zZr~C`PAZl_! z_?&#fYl`LtYucWnAHDlT`Hkev2VHD^?i0 zrw@l=6E{RlL|IF|M3a#33`;ik49$(3mghan^pVM_t#0_Lsn)V8qgTeMVX7lZJx#TP z>cdarfKF{(OO#^LRhRgKD(vx6PES$8A`H`%B{%oRx}X;gvUX$!N2!@era;Vak`cow zVfei*rMa$QiTRviT5>Y4C&pPCQlVioJ#V!cMmpgbZUs*}Mh;D+Xdb=7FbP;b!%Ujv zMj{37wM3Inm~1oh1z6V-Kuxrbb>NElu~`_0Bs(cuay~hk&LuT8!^ng4sU^^flqG6O zGs$+K_)Z`O#y@1rW?>l6meO<{eJUNg1GstPJh&0kVX(98FZmaOyQ{(7wP1W&dJyR@ zzgv2DArh@dq7S+^&1I^=!FqS^+{tS2KtmF`JHC=U;kIeH(c$<0kh|a2TP~K0^>A1D z_0sDL;b=7+T?qGA!~M6yweY@5Xn$q@@k;QGdgP@C+hcR%bEB2M{q?Q;zXDC6hC;e~ z>RmnMYo%)oUH#Rr{>SUQ8#<=nXlx|@&IMm@)z>?_ciy+{eyF#ydGPi-3$MLheeLba z;OWZgvz2of7S3I)p1W8%o2sZ26j9m9BvxT`R*O#4y~wf)k5E^oH<|dpZ{k=z`-R0v()bnZ#)(y zccbByljcr6@JS~wyW#5HlG?I|4btt|=G_+jLmaYTGBj(7^5HM4uW zn~RI`3M7zC)&11&z0vTk$m1REEWj`M82Lm=@uMWgUx*RFA#;;E*WA)-ZFO4(WlcK* zu0|Kn+;v@kiT|iygf)uF3*wHn!d`DdT7^53jXTmcX&HA?fTF1VDkNLD_&GUM(zE$- zlOaG)`QkKlk!l*=iP=lQVTjA@+elO-xa}2)uq^!)BCu^Jx{L@bL>)w6+fm^`l-hnS zODFO)Wl~LTLCA^;t#s>cF{5D{E!Zwpf&gI!T4MyeF+tnG}iyjaG+b-5Be z@_$J9fMrTanOR>8s?*Z_K=(}FFFXGju)#xpvlcp0@t>$4d-KnM6SuE?CVqD2dSsUS zwe$;V_Hs>qd0stm>tZc3@TcIx`M`;aa^ma9k^dy?|LajF37+H(Jixz&+&@6>3VVmP zh<69VL#lAk9~kNp?)6B}hKCotShC3sO(d+g1Rf!H;6U4-NL(3DrkxRn&jg0|XND<} z?L%@1$q^*SkQ@hcgRphjM(AhwK(PtlBaI6{J|GRAm$p?l?P(CKkG|v77m|zEZz#Re zp4n7`Ky_QNt2@FX_8LdH9_b`E^UAF7^MMAz_FU(jc5B~_%hl+?$2P-R85fNzb_`gX z;bs_XzSv^Ui!CmQ!O9Cwm?MC{5|@Nd@a!e0OY literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b777f7188e7ce6ca74b354ed832af0766cbb3f1d GIT binary patch literal 322 zcmX@j%ge<81Uq}@WU2${#~=<2utFK1rGSj-3``8}3@HpP3@MDM46B(Ta#4(xOq$He zFdZP85z6_@17u8RNM(p(Oks#(s$|q;y2b0BnV#sJkyzqdRFq%jr^$SaBR)PaF*h|n z{uWn!d~SY9X%2|Z6CYn#nwSHTS;_DjWaKZ^#DW5Sh>~Lc;*7+i)D-=6pn1ta^Yl|e z1{UiTR2G3;1|~obDF*qWh2aJZM-c~1+z;pwcK=|o6CkSDK~i9OP9W7%Q6rTOF*LD&+X;Ql=6k00Kiei%xfg1IP{2145{uC!j$z|DiCbq+R*UYR_ zIRXhO<%mSY!K#GRD=0m1EI004wpye{QzI2|;?`g!q@I{rdp9{Cg*m+W-t3!i-n{+Z z{4zKg2W?)|Bmn;4PB4@{=*~0H1`t5O1a)Ck5I7c1aZ?ll5}@=*J`{SfA}9c; z+y^jnDE3wk`y`uEOVpx&a!(Uz3>uD6HVs0S)|eaF#`Yev%Kcb>V%hkkyUnIHKwwjV zU{hQNNJ26cuCrAii6N3gB>PBVaZ0!r2~pHX5g3Q+eL2i8+KOojnkv*P#$45KO2~0) zR)tX2XgXEBiA@?c8Z{p%q--}3O_VW$$Z~3iNobr9Q{P3nw%wvi-G<1dNmSoL5Hc3s zUnuvBxmS$@T?!&cis1fK(rdSjQI9j8I2b2+PWFALFbt&B*PY$3d5Sf6HbbNbrU)O$Imgxv0A&gR-+ znh9A}iayEP0ZE0asm#>$Urarc2|5&RFU>8lPWzNBV7-ZM3M$UWK~O*!C_22?t%74L&6qCHa<_FTwB0kYPNzBcJjRlKokXY_o#cu;gNzUxlv-smTtkyFAj5^YF8rS*DzzV_e zfUbYmG|c`NiPeuLb=|Nm+cBJ)Z4nkDZ`roV-W~pM){#@SA%25#{s8b5HsQn6^z5B#?ijhPpcAPB#KOV2>;I0D4PH&=cY U(*KC(1XeF**tufnn7cmJzr-VqmjD0& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..167eaa918efa9c1b871f87c8d592a34c7cb3260c GIT binary patch literal 3393 zcma(UTWlN0aqq>qMC!qalqK;bMUt6TQevl8VXLtvOI1=ksuQ~?LIl2Ec}MAJilq0B zmPC_AVB~`-TqJ@&D8nwS00G*<{qSe|=L8Mlq8}yA7Gy6fU?3>a{LzsC73xoC?|8H< zOGQW0%7LC(GnA5;V?GBO0>+yXo-Wd4aRnv zlN~WS2z!8Q1FmzG3EOqQb=HN{B{_Kxr>-cHjCF_FI9j)1S70-t#3pFO6TMZCSJDcWRYBB*B{fXztSTpB7;YJ>_)<#B zBxH45T@_7~V2 zt(*S7;>ER#75@;-T(yB=Anw`_kiR(&ZGj|;QUo`^2Goe}&w*P+T5~OKN$jFlL?tQ< zQOiU0;=AvvA^AN-L6rBq5j+BXa^fD4=q!93IqK33LfF6m-Y7|b__0!(Lg$V7ZVv2p z?oka>%j);R);yhOK7>S`H+iWloOX&w@ep8d9lcnnLe^*A%)jy~4bQmZ`n` zRb=wR%)Ih1h_~|iCX2$H&T7h%j9b7;+)9uaknRy;vKC!5(<{tkA7R>(sb$D6oimfp z^(^O9lFvK;xAdFXY^%+JM+x0NH}v0iMXXH$6SYUmuIdq%ha+} zI@;#5!MV5QrcS>(bwRlB<1@4KyrS~Sl*U7=%JxHtNhncKQ&P$CmC@zp<eak!)(@bI+fJqq&9kCH7)D5DX1T5O>ddXXp1SVWDT`C z_wMDNyfu@xsnJwg{=qkW;82*=?TgSIWvuF~Bx<5=CnYVF(RAAt87r~XXDy5uK^u?d z%NbeK_?W09Wa&7sd;*_Ww$rLnrjSe?D14Sa&g-7twG5MIh0&w4uROQoI0S;~DNzgCjgpRe zVga0I8mTk#N>sO}#Z{1zF!_QEZ&gx1NO=b^Dm*3$l^w9$f9^Row!3wZ`%zCqbm4IT zMf=)E*Q(y|BX4-KJyhaPRN5y?p2^2OCu=Vqu7!p+q+cgLO;(3bm4{E2hNeor)0_Ul zozA=6x4LilRQ$tFoLq;iV6S_e&UdKIu23<*maq9bi>+&|Ro_V2H&P3PYF)kS(%s~( zWVP=|x$j7+_h`xgdM(gRI>WWKYt^n-%U!QNb=v~1g*WS+$kSPMhsy5I#?eRaVPe$4 zvHL64i8JMiGo@o^A5K49`Ahy!`G=_zf3eaolsv-Y_U_{7+UU*kg1zSJD!SL)>+==g zcM9C5zk7Y+?ulC`HgfmhsT`W9_`hFp{oUJXczU+d{&vapcFo)QNxbCcOAh|uPrHyW zQ1u=zdk=q$DB=(~{nUew2V9B&VWoZU;rWv1+<)o}S=&)b(tX}D6`VPS{&=iowwL+R z>75NQUj{fBhh5~kmK2v{LC{@-V7~HT>=A^^88Kn5VA7;9DYTe8*Dxt=(91LFgp7|8 z2D#MVBHTg7q>p3rPBM@i2I>T$4^f?Fxsg);Yjp(lzO+S1-Q(hd8-rU2NIlTQ^{>kt zr+?$$_))oU{3(LDsgtM+J26p){RE8=6b7UZKwTJXJWj{V$LZKY<8eA>e2I;(<>mh+ zXuX$BPYvr&kRzHN?KmdQL*BsT9}3b`RZ@Q;$`th#@_mKe@cT9L{u53915It)#;N{q J5g 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 0000000000000000000000000000000000000000..f2bc482a303e4fcd59b9459dbdcef7cd56f11889 GIT binary patch literal 120 zcmX@j%ge<81kq`;GePuY5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!5>G5B&@WETNX<7ip^qb$E{p*%xOF@3Ru^>zjG@9^p0EyCVL17`Kco+|pVdpEmFgr7RGfUV; zLs3l1$q=!K)$CVKQ}ym;`kb(>Hol4v{_ZtB(xC*PadX_r!C{E~U!``&xs_x|7f zn9ZgLtgq-7wJDL1-|!GGxr=msKo*EeOwJ;8PUASld5f_;OPiP5_ z@MMCR;$31&RH57=yo^grhIAR|2`ViLU1}<%DnL#4P}9wnntp~NDi??)yHST-x#0RW z$2Kfg2$F9b9zE}v)C#2W8nw(ISI3in)39o$;XAAnDTpA~J#=;gvOp-&IFo3+$!UVg zYoaM=Qd3l=KsjAC?5p&G8^{$#4W9;b!-eNyhsUW;%c>kGx^5eFs_Q{Y*FkH;LOi4E zHyVc3<;bTeOK(k;0{QaAv2Y(Nohwb19+3`O@_eV^x}#pDO6!I;fEUa+j9sp(QrnFqNg`A`halv~>7Ns>{uo>7`7p!R#}$cg4j>kZ9Sa zq~?ND6y>u%tp~j0K0|9Dzo~l7)-7sZ^{avC8h*7LB(Ff4^{U}jgMODeuH#WtuQ;|( zZNIh#_f(#}0=MC=aVx$nL*_ik4kTmRrwj`SOQRCk0NR4phhJv~s0-u+at*1Qmf!V{ zkZW;ldn9_eaj*1nr}>+N4Z^!h53!4SX+IrPO)ieqN8Eo}k%@0@>^)X&VdJ_LeH%Ao zY_``5{(o4pg}2uaLD8bOTj;sdt7pCP(A!Hy70WXR*Hc`qc|}HVG-`~R#c78Xqt+XZ zI&bFi<^p(0K5E&~=-SMot>od^D>c)kcBmkFSydR0g{bwg<5(WS9soh=J;Q3y5@QZy z{Yc#l087fF*0hHuRb&GwnC}iAsynVejT6b6*}GBahhJu67kl>r%#vTyxlfPXJNDVF zh1>UUFDYN=9_3m)jxVQ=e^>Zn-}n1|&hKgM9dG9+TA7K}e-r(7>ha0R<&%@GlUgW$ zzMU_(GUZsly5QV*eo7zsa{uFzQ_CZ#S|jgI3h7y)BMB$qzn!{hRG8`6ILAbEn(lkzb|4gM%y5tAFtcsn8LKJp3nOkzRD|h3SdY z09WS?09#MbaE|gg<)}pE?wl0x9R54{qikQI{zao{IffbZMH61XNtp*>3KG8IUDs=7 zkX#@6Wi_!mzR*jA9Rv`FRt;i_rf!bd2+|N>U_Go}h*)8;Ee@_`A#RY(`FECcLo4D? z7&-72YL>%l|+9(y|5HlHz!4Td`3$P1;xnMXhAXGG$XzjIan63+-5ba<)T!Q^RkS*r|2np^IpMw zi=^bs`$|e)Df#n$fstaM6wC(&?<WfGTsMS(Orufwf(yP_~Ss>gBCf}zZgVQtf8hvKq`##ou%HLAt3VQ#rbaVhvoJVyx_o=H^7%chj~ zm~!4*^}t7BX~!|Pc1qN8HmbXn$F|DWgJOkRTU4*GVpb(QpzD@VqPosQx?U=ql_K&H zU4K+Dik+EQW-Yt8x~XL|n!cG^eIXs;SAQ5bc6UFpw{~dBus_(^F`U`mDhtDmAQ)#h zgqZY1YK^}4ILn5*GbbP6B24!m!Sv*b>CO90l2=S$-ft@Tfa%W%s{u9017IQt?Z=5x zamqZj%gPVwmZLv1c(lt?v*6e~ZkD%feVba88EAhV-hy>HXbd*y2?HEbbqXND=x+-2 z3+{PjWYrLhqP{UCaUh(eH#gRJ=x%OpMayjH_tHGt<+X+7jddPhSj(*IOIlXTr8kz> zvOJtwThR2(t#o!t5eNiMmD!gYOy>XcOJ=$t6p-4cL4ei#7ZfhX?+-z%=~!AO47V|rhammXIvAD}_g z^Ch?uWQ4I!kO}q3C>>)D?i22j@&AZSq(>%jEaQC@Ja%oA20@QdcaFwhg^%arG)5Dr zKy8qSoG6h5iJ^`}6eXY}#`ut^8`QRqZJMKxD%5s3fx1Vt>=MYR9_}~k1EW%O*cEu^ zNwZ)S%iFpQOQ3e>1(U~^(f1zenL92+1(RLI_5PCkEr_jnXJh?=vE`Imm4}Vv@@#Y8 z1Bgaj9UcrTQ4=O%D|Q`;2rCh4AL8pZBtJ*;5t5IQ`~nCk#R5d(4SW|FIES1Fy@7E2 zDNZ0kG5ZfdAU{VZpMC!HbBM-^4Kj5&qyBm3n`_M(?OQThxA!-me%T<};rR>Cop0pk z`T1J5p4p#yy3!!?hm%v!ufI+=C+BJ_b#L8#8fuWalfmKC*Xic)h1$~IdV^emZ@ivq z&fKisu8-6o9wg5<$j!s)i?2tT({nX#?|MCc5I)x=b8Z%(x_w#j|5Q4xd2cJ-(1v0E z`d)=rsF|boZrQRa_d9^0hZP%YTE))3@C|xH*jxBuz>H~7i6Oz1wcw6PLfA==Os!vsT2o+uzdT|Bf78K`xReyeMQ< z|A5w9xwolpY75+-+stN{vrC*TtYtOs({j1B++clL1YH=CpTW-_0|HGbI$B$7#l~w( zZaaz$Ki1qcq2S498iAR2&m`JR49@~)hb$BK62-9WFDTi zi{kli$}nLPQCNR8wdTYXtcgu>#XStloP3!7aWU&JS0nw0 za0?du5H@xXz!s1l@YJsmLz0D`jdx1$)c8b$OtePEn`GS8P6((ePAL3KVVDkFf#`y= zBEp0RcB`gg!3CN5V9qrRUiA1V-2Z1g_y5`B%w-roe5h}(Tq>03>SI2h!|6 zq|0xlOKnA#FVxrfXW9}l?LbhD?b-E(wggN&<&l&1Q*8-`cE~GVt{2)84DCojzPS%L z4DB(GoIoCic8JI$_2IhNmVjx;Jn|WLXJFbPMOIM`hW3yw&+e=E#o2biBd7L1{x_^o H3B&&v=45ZM literal 0 HcmV?d00001 diff --git a/app/schemas/__pycache__/common.cpython-312.pyc b/app/schemas/__pycache__/common.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c1921f8491475f886d86774408c0b593d5f3471d GIT binary patch literal 765 zcmY*Xzi-qq7`4w%E<|@NQ0~gFqAndGAvz;fNY^7hD3GG6U9wmvcFT#xIh@>(?GRYu{O5LseE3~WeuES<2EJE4BV`<_2P+n=BPt=)EkjxYT4a03GH(+rkjFUV|P zlXJiTgE2^82%+JPxRIa`LC^pLz|a|Bm}3qvaJ?$Ds=B4?Hb)m|QMHSv=lnJU4=U`7 zaia6JU7GP%DdTZe%WBm9R59Ds=^StnLI%PHgCSy#5Hl2742L$e!e(K6P37t>#xW0Q z!d3GPjoxv=ltoXu$Q6?GRJlyts*TATQ#yE^HYH#R2w{+ViOPR{ow1`v66ppm-3NxNX%C4^-c(!brW7`H+ zG$@qiQmqm~g-COn4^xp5Qs3m35mjAw4ZFPrvo5=-;S(s`_Q&Tnw7TDMx0V}apq~wM z{R^bIk#a@_qaq(h()=QM-#}$owd>WBE}fma-6wKW>&$1#wlw@f5W*jz^9wwf+ZMb# VU7NBw&}4oc!S(6mzd)y|gMVo|vpWC) literal 0 HcmV?d00001 diff --git a/app/schemas/__pycache__/indexing.cpython-312.pyc b/app/schemas/__pycache__/indexing.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5486f51a62438923f6ea0ded2869b7d86b416655 GIT binary patch literal 2698 zcmbtW&2JM|5Z{lt>u<;TutNf=3JD+E(4dwJRH&-jI1w~B;1H=@wOTEH4`O8Rnzy!8 z-;4@UdUH=i>V;z~xc86f3B?EVBu-UTA@xux?uAol-rCqPsnn_~d3JW@ea!scZ|3=z zR4T5(^E3OQyhRk{SNs?}W?wn@1C+-KRj67~s;aH3sB0Cis@uBM^$MvPwox^0Q+|_5 zq#CuOs-h|D3N;=o)MUm}JzUR@NoxeG(GhE0T4P|1vjkJ0LNxf;Noh}jJvlNuC9Ns2 zrbn!4X`KLTX2d!nt&?D#I<#^|+NZ%jGctOznYD7SaBE8yB=0q<@Q!ZQ>Sf<^E0!i= zpSl6N?o(C~##)(GDEO0k&V7EvZB~4jmT>am=_jom+yKjCg(QEzhk5JAVBYuJw~D;?=U}RG4?Sz9UG@ z1sN&%UY&V$k?bqZjvMS0tf+`Oj^|dHereVRd62y^?!R=#*kn5+4X z{H;76i}@Ycg`>djf+UECN;j2lt$}#37RHF~LovQ#|5xA{#<&gKXN0PKh8t9SroX8J zlSRNM&xaazl*L#?o*AJgjt5(O#1=stoMTiZ!dxj9gwXuj@CYS!3p@=YB~`7&Sne_} zd1ynbhX*O=U@MXn$1zz#2MP=@_z=V+r5i;KkMVlpWNUr*i+1U|ulLEx?x{1t?Q?q< zzkk0&&V|Wye^iqc`g#h62qX9Sto};NFNu)^vAlY*=(QE$gn+?`rw8(-Qv?nH#vI{x){M%T< zW&nMw0W~~c0K>{1&*wdefTO^s6<{BPW1hrl^F-SYmTrX6E&+4rkEtYi}^IWs_GaD;prw&LeeXdpyNo+#_n zRV44YQ1b)wa;!ruIR??l>B{^s<2riTt8*+cIfu(SkKpH6@4JLH6d%AZKrtl1{63lQ z&ds;3w=bf0Y!2_OeKOZwI@ikYUTSYVPn_(Kb0L_xM!}jAgu(v`jz|i#-mJk1%gsf! z)}*diFPFFhKG}@K6kMn){R?%a569SLhm@>QO)416r3*U>BC zRsS*n1O_8jg9eB{byZbgD%qFH@!yrxzbVT-Q&-QoFZUF9^^%(U&K~IS>curRw;Qxq z@hjJh6ZLG{>M8K*r8V{R9_aAGQJK9jQP1F*nfBJ+5{{bbr47|;H~&)LC87BnuRbr= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..11fa637220e8b6054bca506981213f3f4e0dbb41 GIT binary patch literal 1530 zcmah}y>A>v6rb6h-P^m{vmySxGmd~b5-TC608v1cf{374#Dy)IRixGCW^&$WKh862 ziKDuLNYGsu7ZkK8{2eq@*10&Qp`bwNkbE?$cyIO$r?U}T{N{bl+xI!Yv|0@U-_N7( zlhPvOcVsR6xbj>^C6XP4Ed+RNh;Es-3SQMh z`%hz7hZ(o89U)~QgXaG<`5p9RCwVE~MaQ?0WjyJYi|v0K)d)Bd>LY+F?H{wwq}>@l z8hwD&W_$R>=+>CEC++RwXWxD}+WGF2e>MRf?FUWm^#?r8%cLu9keD6StFU&vv$fkb zIVO3p>M8D8HJoPs%t)G^qQ8ZRUwe0}m*Ad!548A}sCxi^+LY4Y$n8JK-Wl1Ox)!~8 inoJ2C(}qpAP6ty0$JD3vHqvlRJr`^q{zc$0Bl0(|xmZd7 literal 0 HcmV?d00001 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