Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion backend/app/api/v1/audio_overviews.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,17 @@ async def delete_audio_overview(
def _parse_authors(authors_field: str | list | None) -> list[str]:
"""Parse authors field into a list of strings."""
if isinstance(authors_field, list):
return authors_field
authors: list[str] = []
for author in authors_field:
if isinstance(author, str):
name = author
elif isinstance(author, dict):
name = str(author.get("name") or author.get("full_name") or "")
else:
name = str(author)
if name.strip():
authors.append(name.strip())
return authors
if isinstance(authors_field, str):
return [a.strip() for a in authors_field.split(";") if a.strip()]
return []
12 changes: 9 additions & 3 deletions backend/app/api/v1/rag.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import json
import logging

from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy import select
Expand Down Expand Up @@ -78,15 +78,21 @@ def _reset_chroma_client_if_closed(rag: RAGService, exc: Exception) -> None:
rag._count_cache.clear()


async def _preserve_existing_index(rag: RAGService, project_id: int) -> dict:
async def _preserve_existing_index(rag: RAGService, project_id: int, source_error: Exception) -> dict:
try:
existing_count = await rag._get_count(project_id)
except Exception:
logger.warning("Failed to count existing index for project %d; returning zero", project_id, exc_info=True)
existing_count = 0
if existing_count <= 0:
raise HTTPException(
status_code=503,
detail=f"RAG indexing failed before any chunks were available in the index: {source_error}",
) from source_error
return {
"indexed": existing_count,
"collection": f"project_{project_id}",
"reused_existing_index": True,
}


Expand All @@ -108,7 +114,7 @@ async def _index_chunks_with_recovery(rag: RAGService, project_id: int, chunks:
raise
logger.exception("Index retry failed with recoverable error; preserving existing index")
_reset_chroma_client_if_closed(rag, retry_exc)
return await _preserve_existing_index(rag, project_id)
return await _preserve_existing_index(rag, project_id, retry_exc)


@router.post("/query", response_model=ApiResponse[RAGQueryResponse], summary="RAG query over literature")
Expand Down
14 changes: 10 additions & 4 deletions backend/app/services/audio_overview_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

from __future__ import annotations

import asyncio
import logging
from typing import TYPE_CHECKING

from app.config import settings

if TYPE_CHECKING:
from app.services.llm.client import LLMClient

Expand Down Expand Up @@ -88,10 +91,13 @@ async def generate_dialogue(
]

try:
result = await self.llm.chat_json(
messages,
temperature=0.7,
task_type="audio_overview_dialogue",
result = await asyncio.wait_for(
self.llm.chat_json(
messages,
temperature=0.7,
task_type="audio_overview_dialogue",
),
timeout=min(settings.rewrite_timeout, 15.0),
)
if not result or "script" not in result:
logger.warning("LLM returned invalid dialogue format, using fallback")
Expand Down
57 changes: 54 additions & 3 deletions backend/app/services/embedding_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING

from app.config import settings
Expand Down Expand Up @@ -167,17 +168,67 @@ def _build_local_embedding(model_name: str) -> BaseEmbedding:
_inject_hf_env()
_cleanup_gpu_memory()

has_gpu, _count, device = detect_gpu(pinned_gpu_id=settings.embed_gpu_id)
_has_gpu, _count, device = detect_gpu(pinned_gpu_id=settings.embed_gpu_id)
batch_size = settings.embed_batch_size
logger.info("Loading local embedding model=%s device=%s batch_size=%d", model_name, device, batch_size)
resolved_model_name = _resolve_cached_hf_snapshot(model_name) or model_name
logger.info(
"Loading local embedding model=%s resolved=%s device=%s batch_size=%d",
model_name,
resolved_model_name,
device,
batch_size,
)

return HuggingFaceEmbedding(
model_name=model_name,
model_name=resolved_model_name,
device=device,
embed_batch_size=batch_size,
local_files_only=True,
)


def _resolve_cached_hf_snapshot(model_name: str) -> str | None:
"""Return a complete local HuggingFace snapshot path when one is cached."""
if Path(model_name).exists():
return model_name

if "/" not in model_name:
return None

try:
from huggingface_hub.constants import HF_HUB_CACHE
except Exception:
return None

model_dir = Path(HF_HUB_CACHE) / f"models--{model_name.replace('/', '--')}"
snapshots_dir = model_dir / "snapshots"
if not snapshots_dir.exists():
return None

ref_file = model_dir / "refs" / "main"
snapshot: Path | None = None
if ref_file.exists():
ref = ref_file.read_text(encoding="utf-8").strip()
candidate = snapshots_dir / ref
if candidate.exists():
snapshot = candidate

if snapshot is None:
candidates = sorted((p for p in snapshots_dir.iterdir() if p.is_dir()), key=lambda p: p.stat().st_mtime)
if candidates:
snapshot = candidates[-1]

if snapshot is None:
return None

required_files = ("config.json", "modules.json")
if not all((snapshot / file_name).exists() for file_name in required_files):
logger.warning("Ignoring incomplete HuggingFace cache snapshot for %s: %s", model_name, snapshot)
return None

return str(snapshot)


def _build_api_embedding(model_name: str) -> BaseEmbedding:
from llama_index.embeddings.openai import OpenAIEmbedding

Expand Down
29 changes: 27 additions & 2 deletions backend/app/services/rag_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,9 +356,13 @@ async def query(
context_text = "\n\n---\n\n".join(contexts)

if self.llm:
answer = await self._generate_answer(question, context_text)
try:
answer = await self._generate_answer(question, context_text)
except Exception as exc:
logger.warning("RAG answer generation failed; returning retrieval-only answer: %s", exc)
answer = self._build_retrieval_only_answer(question, sources, error=str(exc))
else:
answer = f"Retrieved {len(sources)} relevant passages. LLM not available for answer generation."
answer = self._build_retrieval_only_answer(question, sources)

avg_score = sum(s["relevance_score"] for s in sources) / len(sources) if sources else 0

Expand All @@ -368,6 +372,27 @@ async def query(
"confidence": round(avg_score, 3),
}

def _build_retrieval_only_answer(self, question: str, sources: list[dict], *, error: str | None = None) -> str:
if not sources:
return "No relevant documents found."

lines = [
f"Retrieved {len(sources)} relevant passages for: {question}",
"LLM answer generation is unavailable, so this response is based on retrieved source excerpts only.",
]
if error:
lines.append(f"Generation error: {error}")

lines.append("Top evidence:")
for source in sources[:3]:
title = source.get("paper_title") or "Unknown paper"
page = source.get("page_number") or "?"
score = source.get("relevance_score", 0)
excerpt = (source.get("excerpt") or "").strip()
lines.append(f"- {title} (p.{page}, score {score}): {excerpt}")

return "\n".join(lines)

async def retrieve_only(
self,
project_id: int,
Expand Down
Loading
Loading