Рабочий вариант
This commit is contained in:
@@ -59,7 +59,7 @@ def load_config() -> AppConfig:
|
||||
chunk_overlap=_env_int("RAG_CHUNK_OVERLAP", 50),
|
||||
chunk_size_lines=_env_int("RAG_CHUNK_SIZE_LINES", 40),
|
||||
chunk_overlap_lines=_env_int("RAG_CHUNK_OVERLAP_LINES", 8),
|
||||
embeddings_dim=_env_int("RAG_EMBEDDINGS_DIM", 1536),
|
||||
embeddings_dim=_env_int("RAG_EMBEDDINGS_DIM", 1024), # GigaChat Embeddings = 1024; OpenAI = 1536
|
||||
embeddings_model=os.getenv("RAG_EMBEDDINGS_MODEL", "stub-embeddings"),
|
||||
llm_model=os.getenv("RAG_LLM_MODEL", "stub-llm"),
|
||||
allowed_extensions=tuple(
|
||||
|
||||
@@ -34,6 +34,8 @@ class StubEmbeddingClient:
|
||||
|
||||
|
||||
_GIGACHAT_BATCH_SIZE = 50
|
||||
# GigaChat embeddings: max 514 tokens per input; Russian/English ~3 chars/token → truncate to stay under
|
||||
_GIGACHAT_MAX_CHARS_PER_INPUT = 1200
|
||||
|
||||
|
||||
class GigaChatEmbeddingClient:
|
||||
@@ -57,16 +59,43 @@ class GigaChatEmbeddingClient:
|
||||
return []
|
||||
|
||||
result: list[list[float]] = []
|
||||
for i in range(0, len(texts_list), _GIGACHAT_BATCH_SIZE):
|
||||
batch = texts_list[i : i + _GIGACHAT_BATCH_SIZE]
|
||||
with GigaChat(
|
||||
credentials=self._credentials,
|
||||
verify_ssl_certs=self._verify_ssl_certs,
|
||||
) as giga:
|
||||
response = giga.embeddings(model=self._model, input=batch)
|
||||
# Preserve order by index
|
||||
by_index = {item.index: item.embedding for item in response.data}
|
||||
result.extend(by_index[j] for j in range(len(batch)))
|
||||
try:
|
||||
for i in range(0, len(texts_list), _GIGACHAT_BATCH_SIZE):
|
||||
raw_batch = texts_list[i : i + _GIGACHAT_BATCH_SIZE]
|
||||
batch = [
|
||||
t[: _GIGACHAT_MAX_CHARS_PER_INPUT] if len(t) > _GIGACHAT_MAX_CHARS_PER_INPUT else t
|
||||
for t in raw_batch
|
||||
]
|
||||
with GigaChat(
|
||||
credentials=self._credentials,
|
||||
model=self._model,
|
||||
verify_ssl_certs=self._verify_ssl_certs,
|
||||
) as giga:
|
||||
# API: embeddings(texts: list[str]) — single positional argument (gigachat 0.2+)
|
||||
response = giga.embeddings(batch)
|
||||
# Preserve order by index
|
||||
by_index = {item.index: item.embedding for item in response.data}
|
||||
result.extend(by_index[j] for j in range(len(batch)))
|
||||
except Exception as e:
|
||||
from gigachat.exceptions import ResponseError, RequestEntityTooLargeError
|
||||
|
||||
is_402 = (
|
||||
isinstance(e, ResponseError)
|
||||
and (getattr(e, "status_code", None) == 402 or "402" in str(e) or "Payment Required" in str(e))
|
||||
)
|
||||
if is_402:
|
||||
raise ValueError(
|
||||
"GigaChat: недостаточно средств (402 Payment Required). "
|
||||
"Пополните баланс в кабинете GigaChat или отключите GIGACHAT_CREDENTIALS для stub-режима."
|
||||
)
|
||||
if isinstance(e, RequestEntityTooLargeError) or (
|
||||
isinstance(e, ResponseError) and getattr(e, "status_code", None) == 413
|
||||
):
|
||||
raise ValueError(
|
||||
"GigaChat: превышен лимит токенов на один запрос (413). "
|
||||
"Уменьшите RAG_CHUNK_SIZE_LINES или RAG_CHUNK_SIZE в .env (текущий лимит ~514 токенов на чанк)."
|
||||
)
|
||||
raise
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ def ensure_schema(conn: psycopg.Connection, embeddings_dim: int) -> None:
|
||||
try:
|
||||
cur.execute(f"ALTER TABLE stories {col_def};")
|
||||
except psycopg.ProgrammingError:
|
||||
conn.rollback()
|
||||
pass
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -103,12 +104,14 @@ def ensure_schema(conn: psycopg.Connection, embeddings_dim: int) -> None:
|
||||
try:
|
||||
cur.execute(f"ALTER TABLE chunks {col_def};")
|
||||
except psycopg.ProgrammingError:
|
||||
conn.rollback()
|
||||
pass
|
||||
try:
|
||||
cur.execute(
|
||||
"ALTER TABLE chunks ALTER COLUMN change_type SET NOT NULL;"
|
||||
)
|
||||
except psycopg.ProgrammingError:
|
||||
conn.rollback()
|
||||
pass
|
||||
try:
|
||||
cur.execute(
|
||||
@@ -118,6 +121,7 @@ def ensure_schema(conn: psycopg.Connection, embeddings_dim: int) -> None:
|
||||
"""
|
||||
)
|
||||
except psycopg.ProgrammingError:
|
||||
conn.rollback()
|
||||
pass # constraint may already exist
|
||||
cur.execute(
|
||||
"""
|
||||
|
||||
@@ -16,6 +16,14 @@ from fastapi.responses import PlainTextResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# So background webhook logs (pull_and_index, index) appear when run via uvicorn
|
||||
_rag_log = logging.getLogger("rag_agent")
|
||||
if not _rag_log.handlers:
|
||||
_h = logging.StreamHandler()
|
||||
_h.setFormatter(logging.Formatter("%(levelname)s: %(name)s: %(message)s"))
|
||||
_rag_log.setLevel(logging.INFO)
|
||||
_rag_log.addHandler(_h)
|
||||
|
||||
app = FastAPI(title="RAG Agent Webhook", version="0.1.0")
|
||||
|
||||
|
||||
@@ -36,6 +44,12 @@ def _verify_github_signature(body: bytes, secret: str, signature_header: str | N
|
||||
return hmac.compare_digest(received, expected)
|
||||
|
||||
|
||||
def _decode_stderr(stderr: str | bytes | None) -> str:
|
||||
if stderr is None:
|
||||
return ""
|
||||
return stderr.decode("utf-8", errors="replace") if isinstance(stderr, bytes) else stderr
|
||||
|
||||
|
||||
def _run_index(repo_path: str, story: str, base_ref: str, head_ref: str) -> bool:
|
||||
env = os.environ.copy()
|
||||
env["RAG_REPO_PATH"] = repo_path
|
||||
@@ -48,7 +62,10 @@ def _run_index(repo_path: str, story: str, base_ref: str, head_ref: str) -> bool
|
||||
timeout=600,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.error("index failed: %s %s", proc.stdout, proc.stderr)
|
||||
logger.error(
|
||||
"index failed (story=%s base=%s head=%s): stdout=%s stderr=%s",
|
||||
story, base_ref, head_ref, proc.stdout, proc.stderr,
|
||||
)
|
||||
return False
|
||||
logger.info("index completed for story=%s %s..%s", story, base_ref, head_ref)
|
||||
return True
|
||||
@@ -60,7 +77,18 @@ def _run_index(repo_path: str, story: str, base_ref: str, head_ref: str) -> bool
|
||||
return False
|
||||
|
||||
|
||||
def _pull_and_index(repo_path: str, branch: str) -> None:
|
||||
def _pull_and_index(
|
||||
repo_path: str,
|
||||
branch: str,
|
||||
*,
|
||||
payload_before: str | None = None,
|
||||
payload_after: str | None = None,
|
||||
) -> None:
|
||||
"""Fetch, checkout branch; index range from payload (before→after) or from merge result."""
|
||||
logger.info(
|
||||
"webhook: pull_and_index started branch=%s repo_path=%s payload_before=%s payload_after=%s",
|
||||
branch, repo_path, payload_before, payload_after,
|
||||
)
|
||||
repo = Path(repo_path)
|
||||
if not repo.is_dir() or not (repo / ".git").exists():
|
||||
logger.warning("not a git repo or missing: %s", repo_path)
|
||||
@@ -73,7 +101,7 @@ def _pull_and_index(repo_path: str, branch: str) -> None:
|
||||
timeout=60,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("git fetch failed: %s", e.stderr)
|
||||
logger.warning("git fetch failed (branch=%s): %s", branch, _decode_stderr(e.stderr))
|
||||
return
|
||||
except Exception as e:
|
||||
logger.exception("git fetch error: %s", e)
|
||||
@@ -87,7 +115,34 @@ def _pull_and_index(repo_path: str, branch: str) -> None:
|
||||
timeout=10,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("git checkout %s failed: %s", branch, e.stderr)
|
||||
logger.warning("git checkout %s failed: %s", branch, _decode_stderr(e.stderr))
|
||||
return
|
||||
|
||||
# Prefer commit range from webhook payload (GitHub/GitLab before/after) so we index every push
|
||||
# even when the clone is the same dir as the one that was pushed from (HEAD already at new commit).
|
||||
if payload_before and payload_after and payload_before != payload_after:
|
||||
logger.info(
|
||||
"webhook: running index from payload story=%s %s..%s",
|
||||
branch, payload_before, payload_after,
|
||||
)
|
||||
_run_index(repo_path, story=branch, base_ref=payload_before, head_ref=payload_after)
|
||||
return
|
||||
|
||||
if payload_before and payload_after and payload_before == payload_after:
|
||||
logger.info("webhook: payload before==after for branch=%s (e.g. force-push); skipping index", branch)
|
||||
return
|
||||
|
||||
# Fallback: no before/after in payload — infer from merge (original behaviour).
|
||||
origin_ref = f"origin/{branch}"
|
||||
rev_origin = subprocess.run(
|
||||
["git", "-C", repo_path, "rev-parse", origin_ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
origin_head = (rev_origin.stdout or "").strip() if rev_origin.returncode == 0 else None
|
||||
if not origin_head:
|
||||
logger.warning("after fetch: %s not found (wrong branch name?)", origin_ref)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -102,9 +157,16 @@ def _pull_and_index(repo_path: str, branch: str) -> None:
|
||||
logger.exception("rev-parse HEAD: %s", e)
|
||||
return
|
||||
|
||||
if old_head == origin_head:
|
||||
logger.info(
|
||||
"no new commits for branch=%s (already at %s); skipping index",
|
||||
branch, origin_head,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
merge_proc = subprocess.run(
|
||||
["git", "-C", repo_path, "merge", "--ff-only", f"origin/{branch}"],
|
||||
["git", "-C", repo_path, "merge", "--ff-only", origin_ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
@@ -113,7 +175,10 @@ def _pull_and_index(repo_path: str, branch: str) -> None:
|
||||
logger.error("git merge timeout")
|
||||
return
|
||||
if merge_proc.returncode != 0:
|
||||
logger.warning("git merge --ff-only failed (non-fast-forward?). Skipping index.")
|
||||
logger.warning(
|
||||
"git merge --ff-only failed (branch=%s, non-fast-forward?). stderr=%s Skipping index.",
|
||||
branch, _decode_stderr(merge_proc.stderr),
|
||||
)
|
||||
return
|
||||
|
||||
new_head = subprocess.run(
|
||||
@@ -124,9 +189,10 @@ def _pull_and_index(repo_path: str, branch: str) -> None:
|
||||
)
|
||||
new_head = (new_head.stdout or "").strip() if new_head.returncode == 0 else None
|
||||
if not old_head or not new_head or old_head == new_head:
|
||||
logger.info("no new commits for branch=%s", branch)
|
||||
logger.info("no new commits for branch=%s (old_head=%s new_head=%s)", branch, old_head, new_head)
|
||||
return
|
||||
|
||||
logger.info("webhook: running index story=%s %s..%s", branch, old_head, new_head)
|
||||
_run_index(repo_path, story=branch, base_ref=old_head, head_ref=new_head)
|
||||
|
||||
|
||||
@@ -152,6 +218,10 @@ async def webhook(request: Request) -> Response:
|
||||
if not branch:
|
||||
return PlainTextResponse("Missing or unsupported ref", status_code=400)
|
||||
|
||||
# GitHub/GitLab push: before = previous commit, after = new tip (use for index range)
|
||||
before = (payload.get("before") or "").strip() or None
|
||||
after = (payload.get("after") or "").strip() or None
|
||||
|
||||
repo_path = os.getenv("RAG_REPO_PATH", "").strip()
|
||||
if not repo_path:
|
||||
return PlainTextResponse("RAG_REPO_PATH not set", status_code=500)
|
||||
@@ -159,6 +229,7 @@ async def webhook(request: Request) -> Response:
|
||||
threading.Thread(
|
||||
target=_pull_and_index,
|
||||
args=(repo_path, branch),
|
||||
kwargs={"payload_before": before, "payload_after": after},
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user