Compare commits

10 Commits

Author SHA1 Message Date
15586f9a8c Рефакторинг 2026-03-12 23:33:51 +03:00
9066c292de Удаление легаси 2026-03-12 21:11:16 +03:00
b1f825e6b9 Фиксируем состояние 2026-03-12 20:40:29 +03:00
095d354112 Фикс состояния 2026-03-12 16:55:30 +03:00
6ba0a18ac9 Фикс состояния 2026-03-12 16:55:23 +03:00
417b8b6f72 Фиксация изменений 2026-03-05 11:03:17 +03:00
1ef0b4d68c Новый раг 2026-03-01 14:21:33 +03:00
2728c07ba9 Обновил readme.md 2026-02-28 09:37:33 +03:00
9f1c67a751 chore: remove .env from tracking 2026-02-27 21:30:22 +03:00
e8805ffe29 первый коммит 2026-02-27 21:28:09 +03:00
1167 changed files with 562032 additions and 3158 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
.venv
__pycache__

271
README.DB.STORY_PLAN.md Normal file
View File

@@ -0,0 +1,271 @@
# План доработки БД для хранения контекста Story и метаданных RAG
## Цель
Зафиксировать проект миграции, который:
- добавляет в таблицу чанков признаки артефакта (тип, источник, контекст),
- вводит отдельный контур хранения инкремента по `story_id`,
- не зависит от выбранного режима RAG (общий/сессионный/гибридный).
## Границы
- Документ описывает план и целевую схему.
- Реализация SQL-миграций и backfill выполняется отдельным шагом после согласования.
## 1) Метаданные чанков (RAG-слой)
### 1.1. Что добавить
Для таблицы `rag_chunks` (или эквивалента таблицы чанков) добавить поля:
- `artifact_type` (`REQ|ARCH|API|DB|UI|CODE|OTHER`)
- `path` (нормализованный относительный путь файла)
- `section` (заголовок/логический раздел документа)
- `doc_id` (стабильный идентификатор документа)
- `doc_version` (версия документа/ревизия)
- `owner` (ответственная команда/человек)
- `system_component` (система/подсистема/компонент)
- `last_modified` (время последнего изменения источника)
- `staleness_score` (0..1, в первую очередь для `CODE`)
### 1.2. Ограничения и индексы
- `CHECK` для `artifact_type` и диапазона `staleness_score`.
- Индексы:
- `(artifact_type)`
- `(doc_id, doc_version)`
- `(system_component)`
- `(path)`
- GIN/BTREE по потребности для фильтрации в retrieval.
## 2) Контур Story (отдельно от чанков)
### 2.1. Таблица `story_records`
Карточка Story:
- `story_id` (PK, строковый уникальный идентификатор)
- `project_id` (идентификатор проекта/репозитория)
- `title`
- `status` (`draft|in_progress|review|done|archived`)
- `baseline_commit_sha` (базовый снимок)
- `snapshot_id` (опционально для session-RAG)
- `created_at`, `updated_at`
- `created_by`, `updated_by`
Индексы:
- `(project_id)`
- `(status)`
- `(updated_at)`
### 2.2. Таблица `story_artifacts`
Связь Story с артефактами изменений:
- `id` (PK)
- `story_id` (FK -> `story_records.story_id`)
- `artifact_role` (`requirement|analysis|doc_change|test_model|note|decision|risk`)
- `doc_id`
- `doc_version`
- `path`
- `section`
- `chunk_id` (nullable; ссылка на chunk если стабильно поддерживается)
- `change_type` (`added|updated|removed|linked`)
- `summary` (краткое описание изменения)
- `source_ref` (ссылка/внешний id)
- `created_at`
- `created_by`
Уникальность (черновик):
- `UNIQUE(story_id, artifact_role, COALESCE(doc_id,''), COALESCE(path,''), COALESCE(section,''), COALESCE(change_type,''))`
Индексы:
- `(story_id, artifact_role)`
- `(story_id, change_type)`
- `(doc_id, doc_version)`
- `(path)`
### 2.3. Таблица `story_links`
Связи Story с внешними сущностями и Story-to-Story:
- `id` (PK)
- `story_id` (FK)
- `link_type` (`story|adr|ticket|pr|commit|doc|external`)
- `target_ref` (идентификатор/ссылка)
- `description`
- `created_at`
Индексы:
- `(story_id, link_type)`
- `(target_ref)`
## 3) Почему `story_id` не в чанках
- Один чанк может относиться к нескольким Story.
- Чанки нестабильны при переиндексации.
- Разделение слоев упрощает поддержку и не привязывает модель к типу RAG.
Итог: связь Story и чанков/документов хранить в `story_artifacts`, а не в `rag_chunks`.
## 4) Целевая модель RAG: Hybrid-Lite
Выбранный вектор на текущем этапе: `Session-first + Shared Cache + Story Ledger`.
### 4.1. Принципы
- Рабочий retrieval выполняется из сессионного индекса (видит незакоммиченные изменения).
- Общий кэш чанков/эмбеддингов используется только для ускорения индексации.
- Источник правды по инкременту Story находится в Story-таблицах, а не в RAG-индексе.
### 4.2. Что хранить дополнительно
- `rag_blob_cache`: кэш файловых blob по `repo_id + blob_sha`.
- `rag_chunk_cache`: кэш чанков/эмбеддингов, привязанный к `blob_sha`.
- `rag_session_chunk_map`: привязка сессии к используемым chunk (чтобы retrieval был изолированным).
- `session_artifacts`: временные артефакты сессии до появления `story_id` (late binding).
### 4.3. Алгоритм индексации (delta-only)
1. На старте сессии сканировать рабочее дерево и считать `blob_sha` для файлов индексации.
2. Для каждого файла:
- `cache hit`: взять chunk/embedding из кэша и связать с текущей сессией.
- `cache miss`: выполнить chunk+embed и записать результат в кэш.
3. Для retrieval использовать `rag_session_chunk_map` как первичный источник.
4. При необходимости делать fallback к cache-scoped данным по `repo_id` (опционально, под флагом).
### 4.4. Почему это подходит
- Нет необходимости в сложном ACL общего RAG на уровне приложения.
- Нет обязательной зависимости от ручного commit, индекс отражает локальные изменения.
- Снижается время загрузки сессии за счет переиспользования эмбеддингов.
- История Story не теряется и не зависит от режима RAG.
### 4.5. Late binding `story_id` (целевой процесс)
1. Аналитик запускает работу только со ссылкой на документ (без `story_id`).
2. Агент обрабатывает задачу в `session-RAG` и сохраняет все изменения в `session_artifacts`.
3. Аналитик вручную делает commit и указывает `story_id`.
4. Вебхук на commit:
- извлекает `story_id` из commit metadata/message,
- обновляет репозиторный RAG,
- выполняет `bind session -> story`: переносит/привязывает `session_artifacts` к `story_artifacts`,
- фиксирует связь `story_id <-> commit_sha <-> changed_files`.
5. Исходный документ аналитики тоже попадает в контекст Story ретроспективно, даже если изначально был без `story_id`.
## 5) Черновик DDL (PostgreSQL)
```sql
-- 0. Enum-like checks можно заменить на справочники при необходимости
-- A) Session artifacts (временный слой до появления story_id)
CREATE TABLE IF NOT EXISTS session_artifacts (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL,
project_id TEXT NOT NULL,
artifact_role TEXT NOT NULL,
source_ref TEXT,
doc_id TEXT,
doc_version TEXT,
path TEXT,
section TEXT,
chunk_id TEXT,
change_type TEXT,
summary TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
CONSTRAINT chk_session_artifact_role CHECK (artifact_role IN (
'analysis','doc_change','note','decision','risk','test_model'
)),
CONSTRAINT chk_session_change_type CHECK (change_type IS NULL OR change_type IN (
'added','updated','removed','linked'
))
);
CREATE INDEX IF NOT EXISTS idx_session_artifacts_session ON session_artifacts(session_id);
CREATE INDEX IF NOT EXISTS idx_session_artifacts_project ON session_artifacts(project_id);
CREATE INDEX IF NOT EXISTS idx_session_artifacts_role ON session_artifacts(artifact_role);
-- 1) Story records
CREATE TABLE IF NOT EXISTS story_records (
story_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT,
status TEXT NOT NULL DEFAULT 'draft',
baseline_commit_sha TEXT,
snapshot_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
updated_by TEXT,
CONSTRAINT chk_story_status CHECK (status IN (
'draft','in_progress','review','done','archived'
))
);
CREATE INDEX IF NOT EXISTS idx_story_records_project ON story_records(project_id);
CREATE INDEX IF NOT EXISTS idx_story_records_status ON story_records(status);
CREATE INDEX IF NOT EXISTS idx_story_records_updated_at ON story_records(updated_at DESC);
-- 2) Story artifacts
CREATE TABLE IF NOT EXISTS story_artifacts (
id BIGSERIAL PRIMARY KEY,
story_id TEXT NOT NULL REFERENCES story_records(story_id) ON DELETE CASCADE,
artifact_role TEXT NOT NULL,
doc_id TEXT,
doc_version TEXT,
path TEXT,
section TEXT,
chunk_id TEXT,
change_type TEXT,
summary TEXT,
source_ref TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
CONSTRAINT chk_story_artifact_role CHECK (artifact_role IN (
'requirement','analysis','doc_change','test_model','note','decision','risk'
)),
CONSTRAINT chk_story_change_type CHECK (change_type IS NULL OR change_type IN (
'added','updated','removed','linked'
))
);
CREATE INDEX IF NOT EXISTS idx_story_artifacts_story_role ON story_artifacts(story_id, artifact_role);
CREATE INDEX IF NOT EXISTS idx_story_artifacts_story_change ON story_artifacts(story_id, change_type);
CREATE INDEX IF NOT EXISTS idx_story_artifacts_doc ON story_artifacts(doc_id, doc_version);
CREATE INDEX IF NOT EXISTS idx_story_artifacts_path ON story_artifacts(path);
-- Вариант уникальности можно уточнить после согласования процессов
-- 3) Story links
CREATE TABLE IF NOT EXISTS story_links (
id BIGSERIAL PRIMARY KEY,
story_id TEXT NOT NULL REFERENCES story_records(story_id) ON DELETE CASCADE,
link_type TEXT NOT NULL,
target_ref TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_story_link_type CHECK (link_type IN (
'story','adr','ticket','pr','commit','doc','external'
))
);
CREATE INDEX IF NOT EXISTS idx_story_links_story_type ON story_links(story_id, link_type);
CREATE INDEX IF NOT EXISTS idx_story_links_target_ref ON story_links(target_ref);
```
## 6) План внедрения (после согласования)
1. Подтвердить перечень полей и enum-значений.
2. Подготовить SQL-миграцию `Vxxx__story_context.sql`.
3. Обновить bootstrap/инициализацию схемы.
4. Обновить репозитории для `story_records/story_artifacts/story_links`.
5. Добавить таблицу и репозиторий `session_artifacts` (session-scoped артефакты без `story_id`).
6. Добавить запись session-артефактов в оркестраторе во время работы аналитика.
7. Добавить webhook-обработчик `bind session -> story` при появлении commit со `story_id`.
8. Добавить API/сервисный метод `get_story_context(story_id)` для повторного входа в Story.
9. Добавить тесты:
- unit на репозитории,
- интеграционные на happy-path записи/чтения,
- регресс на отсутствие зависимости от типа RAG.
10. Добавить миграцию для `rag_blob_cache/rag_chunk_cache/rag_session_chunk_map`.
11. Внедрить `delta-only` индексацию для session-RAG с переиспользованием кэша.
## 7) Открытые вопросы
- Нужен ли отдельный справочник для `artifact_type`, `artifact_role`, `link_type`.
- Что считать `doc_version`: semver, дата, commit, hash файла.
- Нужна ли soft-delete политика для Story.
- Требуется ли аудит (кто/когда менял `summary` и связи).
- Какой уровень обязательности `chunk_id` (опционален по умолчанию).
- Нужна ли TTL/очистка для `rag_blob_cache/rag_chunk_cache`.
- Делать ли fallback к репозиторному кэшу по умолчанию или только при explicit-флаге.
- Как определять соответствие `session_id` и commit в webhook (1:1, последний активный, explicit token).
- Как долго хранить `session_artifacts` до bind/cleanup.
## 8) Критерии готовности
- По `story_id` можно восстановить инкремент без исходной сессии.
- История изменений не теряется при переиндексации RAG.
- Аналитик и тестировщик используют один `story_id` как общий ключ контекста.
- Схема работает при любом выбранном режиме RAG.
- Session-RAG поднимается быстрее за счет cache hit по неизмененным файлам.
- Артефакты аналитика, созданные до появления `story_id`, корректно попадают в Story после commit/webhook bind.

1282
README.md

File diff suppressed because it is too large Load Diff

161
README_old.md Normal file
View File

@@ -0,0 +1,161 @@
# Агент для работы с проектной документацией
## 1. Общее описание
Приложение представляет собой backend агентного режима для работы с документацией и кодом проекта.
Система решает следующие задачи:
- индексирует локальную копию проекта в `rag_session` и использует ее как основной рабочий контекст пользователя;
- принимает webhook коммитов репозитория в `rag_repo` и фиксирует контекст изменений по `story_id`;
- ускоряет построение `rag_session` за счет переиспользования кэша чанков и эмбеддингов из `rag_repo`;
- обрабатывает пользовательские запросы через `chat`, `agent`, оркестратор и специализированные графы;
- сохраняет quality-метрики, Story-контекст и артефакты сессии в PostgreSQL.
Ключевая идея архитектуры:
- `rag_session` отвечает за пользовательскую рабочую сессию и всегда остается основным источником retrieval;
- `rag_repo` не участвует напрямую в пользовательском ответе, а служит фоновым источником кэша и контекста коммитов;
- `story_id` связывает изменения аналитика, документацию и последующую работу тестировщика.
## 2. Архитектура
```mermaid
flowchart LR
User["Пользователь"]
Git["Git репозиторий\n(Gitea / Bitbucket)"]
Chat["Модуль chat"]
Agent["Модуль agent"]
RagSession["Модуль rag_session"]
RagRepo["Модуль rag_repo"]
Shared["Модуль shared"]
DB["PostgreSQL + pgvector"]
Giga["GigaChat API"]
User --> Chat
Chat --> Agent
Agent --> RagSession
Agent --> Shared
RagSession --> Shared
RagRepo --> Shared
Chat --> DB
Agent --> DB
RagSession --> DB
RagRepo --> DB
RagSession --> Giga
Agent --> Giga
Git --> RagRepo
RagRepo -.кэш и контекст коммитов.-> RagSession
```
Кратко по ролям модулей:
- `chat` — внешний API чата, фоновые задачи, SSE события, диалоги.
- `agent` — роутер интентов, оркестратор, графы, tools, генерация ответа и changeset.
- `rag_session` — создание и сопровождение пользовательского RAG индекса по локальным файлам.
- `rag_repo` — прием webhook коммитов, определение `story_id`, фиксация контекста коммита и заполнение repo-cache.
- `shared` — инфраструктурный слой: БД, retry, event bus, GigaChat client, настройки.
## 3. Типичный флоу
```mermaid
sequenceDiagram
actor User as Пользователь
participant Git as Git репозиторий
participant RagRepo as Модуль rag_repo
participant DB as PostgreSQL
participant RagSession as Модуль rag_session
participant Chat as Модуль chat
participant Agent as Модуль agent
Note over User,RagSession: Первая рабочая сессия: кэша репозитория еще нет
User->>RagSession: Создание rag_session и загрузка файлов проекта
RagSession->>DB: Проверка cache hit/miss
DB-->>RagSession: Кэш пуст
RagSession->>RagSession: Чанкинг и расчет embeddings без repo-cache
RagSession->>DB: Сохранение rag_chunks и rag_index_jobs
User->>Chat: Отправка запроса в чат
Chat->>Agent: Передача задачи
Agent->>RagSession: Retrieval контекста
RagSession->>DB: Чтение rag_chunks
DB-->>RagSession: Релевантные чанки
RagSession-->>Agent: Контекст
Agent-->>Chat: Ответ / changeset
Note over User,Git: Пользователь вручную делает commit и push
Git->>RagRepo: Webhook push
RagRepo->>RagRepo: Определение provider, commit_sha, changed_files, story_id
RagRepo->>DB: Запись story_records/story_links/story_artifacts
RagRepo->>DB: Запись rag_blob_cache/rag_chunk_cache/rag_session_chunk_map
Note over User,RagSession: Вторая рабочая сессия: repo-cache уже существует
User->>RagSession: Создание новой rag_session и загрузка файлов проекта
RagSession->>DB: Проверка cache hit/miss
DB-->>RagSession: Найдены cache hit по части файлов
RagSession->>RagSession: Переиспользование чанков из rag_repo cache
RagSession->>RagSession: Пересчет embeddings только для cache miss файлов
RagSession->>DB: Сохранение rag_chunks, rag_session_chunk_map, rag_index_jobs
User->>Chat: Новый запрос
Chat->>Agent: Передача задачи
Agent->>RagSession: Retrieval по новой сессии
RagSession-->>Agent: Контекст из session-RAG
Agent-->>Chat: Ответ / changeset
```
Что важно в этом сценарии:
- первый запуск индексации может быть полностью без кэша;
- после коммита `rag_repo` фиксирует контекст изменений и наполняет cache-таблицы;
- во второй и последующих сессиях `rag_session` использует `cache_hit_files`, чтобы уменьшить объем новых embeddings.
## 4. Инструкции к запуску
### Локальный запуск
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 15000
```
### Запуск через Docker Compose
1. Создать `.env` на основе примера:
```bash
cp .env.example .env
```
2. Заполнить как минимум `GIGACHAT_TOKEN`.
3. Запустить сервисы:
```bash
docker compose up -d --build
```
4. Проверить доступность backend:
```bash
curl http://localhost:15000/health
```
Ожидаемый ответ:
```json
{"status":"ok"}
```
### Основные адреса
- Backend API: `http://localhost:15000`
- PostgreSQL + pgvector: `localhost:5432`
- Webhook репозитория: `POST /internal/rag-repo/webhook`
### Базовый сценарий проверки
1. Создать `rag_session` через `POST /api/rag/sessions`.
2. Дождаться завершения index-job через `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}`.
3. Создать диалог через `POST /api/chat/dialogs`.
4. Отправить сообщение через `POST /api/chat/messages`.
5. Настроить webhook репозитория на `POST /internal/rag-repo/webhook`.
6. Сделать commit с `story_id` в сообщении, например `FEAT-1 ...`.
7. Проверить заполнение таблиц:
- `story_records`, `story_links`, `story_artifacts`
- `rag_blob_cache`, `rag_chunk_cache`, `rag_session_chunk_map`
8. Во второй сессии индексации проверить поля job-статуса:
- `indexed_files`
- `cache_hit_files`
- `cache_miss_files`
### Полезные замечания
- Текущая chat-модель: `GigaChat`.
- Основной retrieval всегда идет из `rag_session`.
- `rag_repo` используется как фоновый источник кэша и контекста коммитов.
- Если в webhook не найден `story_id`, commit-контекст Story не будет привязан, но cache-таблицы все равно должны наполняться.

Binary file not shown.

View File

@@ -1,20 +0,0 @@
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

View File

@@ -1,20 +0,0 @@
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(),
}

View File

@@ -1,11 +0,0 @@
from app.modules.agent.engine.graphs.base_graph import BaseGraphFactory
from app.modules.agent.engine.graphs.docs_graph import DocsGraphFactory
from app.modules.agent.engine.graphs.project_edits_graph import ProjectEditsGraphFactory
from app.modules.agent.engine.graphs.project_qa_graph import ProjectQaGraphFactory
__all__ = [
"BaseGraphFactory",
"DocsGraphFactory",
"ProjectEditsGraphFactory",
"ProjectQaGraphFactory",
]

View File

@@ -1,58 +0,0 @@
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}

View File

@@ -1,26 +0,0 @@
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()

View File

@@ -1,128 +0,0 @@
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

View File

@@ -1,519 +0,0 @@
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)

View File

@@ -1,28 +0,0 @@
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

View File

@@ -1,44 +0,0 @@
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

View File

@@ -1,27 +0,0 @@
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()

View File

@@ -1,79 +0,0 @@
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"

View File

@@ -1,271 +0,0 @@
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)

View File

@@ -1,38 +0,0 @@
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}

View File

@@ -1,32 +0,0 @@
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]

View File

@@ -1,34 +0,0 @@
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"]

View File

@@ -1,29 +0,0 @@
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,
)

View File

@@ -1,191 +0,0 @@
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)

View File

@@ -1,17 +0,0 @@
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

View File

@@ -1,46 +0,0 @@
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

View File

@@ -1,62 +0,0 @@
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,
)

View File

@@ -1,27 +0,0 @@
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)

View File

@@ -1,3 +0,0 @@
from app.modules.agent.llm.service import AgentLlmService
__all__ = ["AgentLlmService"]

View File

@@ -1,14 +0,0 @@
from app.modules.agent.prompt_loader import PromptLoader
from app.modules.shared.gigachat.client import GigaChatClient
class AgentLlmService:
def __init__(self, client: GigaChatClient, prompts: PromptLoader) -> None:
self._client = client
self._prompts = prompts
def generate(self, prompt_name: str, user_input: str) -> str:
system_prompt = self._prompts.load(prompt_name)
if not system_prompt:
system_prompt = "You are a helpful assistant."
return self._client.complete(system_prompt=system_prompt, user_prompt=user_input)

View File

@@ -1,44 +0,0 @@
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

View File

@@ -1,15 +0,0 @@
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()

View File

@@ -1,18 +0,0 @@
Ты анализируешь, есть ли в проекте существующая документация, в которую нужно встраиваться.
Оцени входные данные:
- User request
- Requested target path
- Detected documentation candidates (пути и сниппеты)
Критерии EXISTS=yes:
- Есть хотя бы один релевантный doc-файл, и
- Он по смыслу подходит под запрос пользователя.
Критерии EXISTS=no:
- Нет релевантных doc-файлов, или
- Есть только нерелевантные/пустые заготовки.
Верни строго две строки:
EXISTS: yes|no
SUMMARY: <короткое объяснение на 1-2 предложения>

View File

@@ -1,27 +0,0 @@
# 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.

View File

@@ -1,21 +0,0 @@
# 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.

View File

@@ -1,12 +0,0 @@
Ты технический писатель и готовишь краткий итог по выполненной задаче документации.
Верни только markdown-текст без JSON и без лишних вступлений.
Структура ответа:
1) "Что сделано" — 3-6 коротких пунктов по основным частям пользовательского запроса.
2) "Измененные файлы" — список файлов с кратким описанием изменения по каждому файлу.
3) "Ограничения" — добавляй только если в данных есть явные пробелы или ограничения.
Правила:
- Используй только входные данные.
- Не выдумывай изменения, которых нет в списке changed files.
- Пиши коротко и по делу.

View File

@@ -1,53 +0,0 @@
Ты 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/<file>.md",
"content": "<полное содержимое markdown-файла>",
"reason": "<кратко зачем создан/обновлен файл>"
},
{
"path": "docs/logic/<file>.md",
"content": "<полное содержимое markdown-файла>",
"reason": "<кратко зачем создан/обновлен файл>"
}
]
}
- Для from_scratch сформируй несколько файлов и обязательно покрой обе папки: `docs/api` и `docs/logic`.
- Для incremental_update также соблюдай правило атомарности: один файл = один метод API или один атомарный кусок логики/требования.

View File

@@ -1,25 +0,0 @@
Ты составляешь план изменений документации перед генерацией текста.
Вход:
- 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.

View File

@@ -1,22 +0,0 @@
Ты валидатор качества документации.
Проверь:
- Соответствие strategy и user request.
- Соответствие generated document плану секций.
- Отсутствие очевидных выдуманных фактов.
- Практическую применимость текста к проекту.
- Для incremental_update: минимально необходимый инкремент без лишнего переписывания.
- Проверку структуры документации:
- есть разбиение по папкам `docs/api` и `docs/logic`;
- один файл описывает только один API-метод или один атомарный кусок логики;
- сценарии состоят из коротких шагов, а технические детали вынесены в функциональные требования.
Если документ приемлем:
PASS: yes
FEEDBACK: <коротко, что ок>
Если документ неприемлем:
PASS: no
FEEDBACK: <коротко, что исправить в следующей попытке>
Верни ровно две строки в этом формате.

View File

@@ -1,14 +0,0 @@
Ты выбираешь стратегию генерации документации.
Доступные стратегии:
- 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

View File

@@ -1,3 +0,0 @@
Ты инженерный AI-ассистент. Ответь по проекту коротко и по делу.
Если в контексте недостаточно данных, явно укажи пробелы.
Не выдумывай факты, используй только входные данные.

View File

@@ -1,9 +0,0 @@
Ты инженерный AI-ассистент по текущему проекту.
Сформируй точный ответ на вопрос пользователя, используя только входной контекст.
Приоритет источников: сначала RAG context, затем Confluence context.
Правила:
- Не выдумывай факты и явно помечай пробелы в данных.
- Отвечай структурировано и коротко.
- Если пользователь просит шаги, дай практичный пошаговый план.

View File

@@ -1,10 +0,0 @@
Ты вносишь правку в один файл по запросу пользователя.
На вход приходит JSON с request, path, reason, current_content, previous_validation_feedback, rag_context, confluence_context.
Верни только полное итоговое содержимое файла (без JSON).
Критичные правила:
- Измени только те части, которые нужны по запросу.
- Не переписывай файл целиком без необходимости.
- Сохрани структуру, стиль и все нерелевантные разделы без изменений.
- Если данных недостаточно, внеси минимально безопасную правку и явно отрази ограничение в тексте файла.

View File

@@ -1,15 +0,0 @@
Ты анализируешь запрос на правки файлов проекта (не про написание нового кода).
На вход приходит JSON с request, requested_path, context_files.
Верни только JSON:
{
"files": [
{"path": "<path>", "reason": "<why this file should be edited>"}
]
}
Правила:
- Выбирай только файлы, реально нужные для выполнения запроса.
- Не добавляй лишние файлы.
- Обычно 1-3 файла, максимум 8.
- Если в request указан конкретный файл, включи его в первую очередь.

View File

@@ -1,12 +0,0 @@
Ты валидируешь changeset правок файла.
На вход приходит JSON с request и changeset (op, path, reason).
Проверь:
1) изменения соответствуют запросу,
2) нет лишних нерелевантных правок,
3) изменены только действительно нужные файлы,
4) нет косметических правок (пробелы/форматирование без смысла),
5) нет добавления новых секций/заголовков, если это не запрошено явно.
Верни только JSON:
{"pass": true|false, "feedback": "<short reason>"}

View File

@@ -1,23 +0,0 @@
Ты классификатор маршрутов агента.
На вход ты получаешь JSON с полями:
- message: текущий запрос пользователя
- history: последние сообщения диалога
- allowed_routes: допустимые маршруты
Выбери ровно один маршрут из allowed_routes.
Верни только JSON без markdown и пояснений.
Строгий формат ответа:
{"route":"<one_of_allowed_routes>","confidence":<number_0_to_1>,"reason":"<short_reason>"}
Правила маршрутизации:
- project/qa: пользователь задает вопросы про текущий проект, его код, архитектуру, модули, поведение, ограничения.
- project/edits: пользователь просит внести правки в существующие файлы проекта (контент, конфиги, тексты, шаблоны), без реализации новой кодовой логики.
- docs/generation: пользователь просит подготовить/обновить документацию, инструкции, markdown-материалы.
- default/general: остальные случаи, включая общие вопросы и консультации.
Приоритет:
- Если в запросе есть явная команда правки конкретного файла (например `README.md`, путь к файлу, "добавь в конец файла"), выбирай project/edits.
- docs/generation выбирай для задач подготовки документации в целом, а не для точечной правки одного файла.
Если есть сомнения, выбирай default/general и confidence <= 0.6.

View File

@@ -1,106 +0,0 @@
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()

View File

@@ -1,296 +0,0 @@
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())

View File

@@ -1,31 +0,0 @@
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)

Some files were not shown because too many files have changed in this diff Show More